test: 302 tests unitaires + 20 E2E Playwright (couverture 94%)
- Unit : domain (GaugeCalculator, Enclos, Dragodinde, XpTable, Race, Tier...) - Unit : application (commands, queries, CommandBus) - Fonctionnel : breeding-workflow, enclos-management, timer-workflow - Régression : gauge-tier, gauge-recharge, xp-timer, level-target, breeding - E2E Playwright + Electron : navigation, timer, recharge jauge, accouplement, persistance des données Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
62ae4c54eb
commit
203c423f19
11
playwright.config.ts
Normal file
11
playwright.config.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
timeout: 30000,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
use: {
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
});
|
||||
106
tests/e2e/breeding.spec.ts
Normal file
106
tests/e2e/breeding.spec.ts
Normal file
@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Tests E2E — Workflow d'accouplement
|
||||
*
|
||||
* Navigue vers la vue Accouplement, selectionne deux parents,
|
||||
* verifie la deduction du bebe, et enregistre l'accouplement.
|
||||
*/
|
||||
import { test, expect } from './electron-app';
|
||||
|
||||
test.describe('Workflow d\'accouplement', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Naviguer vers Accouplement via la sidebar
|
||||
await page.click('.sb-item[data-view="accouplement"]');
|
||||
await expect(page.locator('.accoup-view')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('Selectionner Parent 1 en cliquant sur une race', async ({ page }) => {
|
||||
// Cliquer sur la premiere race disponible dans la grille
|
||||
const firstRaceCard = page.locator('.accoup-race-card').first();
|
||||
const raceName = await firstRaceCard.locator('.accoup-race-card-name').textContent();
|
||||
|
||||
await firstRaceCard.click();
|
||||
|
||||
// Verifier que Parent 1 affiche la race selectionnee
|
||||
const selectedParent = page.locator('.accoup-selected-parent-name').first();
|
||||
await expect(selectedParent).toHaveText(raceName!.trim(), { timeout: 3000 });
|
||||
});
|
||||
|
||||
test('Selectionner les deux parents et voir le resultat', async ({ page }) => {
|
||||
// Selectionner "Rousse" comme Parent 1
|
||||
const rousseCard = page.locator('.accoup-race-card[data-race="Rousse"]');
|
||||
await rousseCard.click();
|
||||
await expect(page.locator('.accoup-selected-parent-name').first()).toHaveText('Rousse', { timeout: 3000 });
|
||||
|
||||
// Apres P1 selectionne, la grille filtre les partenaires compatibles
|
||||
// Selectionner le premier partenaire disponible comme Parent 2
|
||||
const partnerCard = page.locator('.accoup-race-card').first();
|
||||
await partnerCard.click();
|
||||
|
||||
// Verifier qu'un resultat (bebe) est affiche
|
||||
// Le resultat apparait dans la zone centrale avec le texte "Resultat"
|
||||
await page.waitForTimeout(500);
|
||||
const parentNames = page.locator('.accoup-selected-parent-name');
|
||||
await expect(parentNames).toHaveCount(2, { timeout: 3000 });
|
||||
});
|
||||
|
||||
test('Definir nombre de couples et bebes puis enregistrer', async ({ page }) => {
|
||||
// Selectionner P1 : Rousse
|
||||
await page.click('.accoup-race-card[data-race="Rousse"]');
|
||||
await expect(page.locator('.accoup-selected-parent-name').first()).toHaveText('Rousse', { timeout: 3000 });
|
||||
|
||||
// Selectionner P2 : premier partenaire compatible
|
||||
const partnerCard = page.locator('.accoup-race-card').first();
|
||||
await partnerCard.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Remplir le nombre de couples
|
||||
const couplesInput = page.locator('#accoup-couples');
|
||||
await couplesInput.click();
|
||||
await couplesInput.fill('3');
|
||||
|
||||
// Remplir les bebes obtenus
|
||||
const babiesInput = page.locator('#accoup-babies');
|
||||
await babiesInput.click();
|
||||
await babiesInput.fill('2');
|
||||
|
||||
// Le bouton Enregistrer devrait etre actif
|
||||
const registerBtn = page.locator('#accoup-register');
|
||||
await expect(registerBtn).toBeEnabled({ timeout: 3000 });
|
||||
|
||||
// Cliquer sur Enregistrer
|
||||
await registerBtn.click();
|
||||
|
||||
// Verifier que le toast success apparait
|
||||
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 3000 });
|
||||
await expect(page.locator('.toast-msg')).toContainText('Accouplement enregistré');
|
||||
|
||||
// Apres l'enregistrement, les parents sont reinitialises
|
||||
// On devrait voir les placeholders "Cliquer ou glisser" a nouveau
|
||||
await expect(page.locator('.accoup-placeholder')).toHaveCount(2, { timeout: 3000 });
|
||||
});
|
||||
|
||||
test('Filtrer les races par generation', async ({ page }) => {
|
||||
// Cliquer sur le chip "Gen 1"
|
||||
await page.click('.accoup-gen-chip[data-gen="1"]');
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Verifier que le chip est actif
|
||||
await expect(page.locator('.accoup-gen-chip[data-gen="1"].active')).toBeVisible();
|
||||
|
||||
// Toutes les races affichees devraient etre de Gen 1
|
||||
const genBadges = page.locator('.accoup-race-card-gen');
|
||||
const count = await genBadges.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
await expect(genBadges.nth(i)).toContainText('GEN 1');
|
||||
}
|
||||
});
|
||||
|
||||
test('Rechercher une race par nom', async ({ page }) => {
|
||||
const searchInput = page.locator('#accoup-search-input');
|
||||
await searchInput.fill('Rousse');
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// La grille devrait contenir "Rousse"
|
||||
await expect(page.locator('.accoup-race-card[data-race="Rousse"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
60
tests/e2e/electron-app.ts
Normal file
60
tests/e2e/electron-app.ts
Normal file
@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Helper Playwright pour lancer l'application Electron.
|
||||
*
|
||||
* Lance l'app depuis dist-electron/main.js (il faut avoir buildé avec `vite build` avant).
|
||||
* Exporte une fixture `test` qui fournit `electronApp` et `page`.
|
||||
*
|
||||
* Chaque test démarre avec un userData vierge (nettoyé automatiquement).
|
||||
*/
|
||||
import { test as base, type ElectronApplication, type Page, _electron as electron } from '@playwright/test';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const USER_DATA_DIR = path.resolve(__dirname, '../../.e2e-userdata');
|
||||
|
||||
type ElectronFixtures = {
|
||||
electronApp: ElectronApplication;
|
||||
page: Page;
|
||||
};
|
||||
|
||||
export const test = base.extend<ElectronFixtures>({
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
electronApp: async ({}, use) => {
|
||||
// Nettoyer le userData pour partir d'un état vierge à chaque test
|
||||
if (fs.existsSync(USER_DATA_DIR)) {
|
||||
fs.rmSync(USER_DATA_DIR, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(USER_DATA_DIR, { recursive: true });
|
||||
|
||||
const mainPath = path.resolve(__dirname, '../../dist-electron/main.js');
|
||||
|
||||
const app = await electron.launch({
|
||||
args: [mainPath],
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_USER_DATA_DIR: USER_DATA_DIR,
|
||||
},
|
||||
});
|
||||
|
||||
await use(app);
|
||||
await app.close();
|
||||
},
|
||||
|
||||
page: async ({ electronApp }, use) => {
|
||||
const window = await electronApp.firstWindow();
|
||||
await window.waitForLoadState('domcontentloaded');
|
||||
await window.waitForSelector('.app-shell', { timeout: 15000 });
|
||||
|
||||
// L'app démarre sans enclos — en créer un pour les tests qui en ont besoin
|
||||
const addBtn = window.locator('#add-enclos-btn');
|
||||
if (await addBtn.isVisible({ timeout: 5000 })) {
|
||||
await addBtn.click();
|
||||
// Attendre que l'enclos apparaisse dans la sidebar
|
||||
await window.waitForSelector('.sb-item .sb-dot', { timeout: 5000 });
|
||||
}
|
||||
|
||||
await use(window);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from '@playwright/test';
|
||||
75
tests/e2e/gauge-recharge.spec.ts
Normal file
75
tests/e2e/gauge-recharge.spec.ts
Normal file
@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Tests E2E — Recharge de jauge en cours de session
|
||||
*
|
||||
* Setup: enclos avec baffeur a 100, DD ajoutee, timer demarre.
|
||||
* Recharge la jauge a 50000 et verifie les mises a jour.
|
||||
*/
|
||||
import { test, expect } from './electron-app';
|
||||
|
||||
test.describe('Recharge de jauge pendant une session', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Naviguer vers le premier enclos
|
||||
const firstEnclos = page.locator('.sb-item[data-view]').filter({
|
||||
has: page.locator('.sb-dot'),
|
||||
}).first();
|
||||
await firstEnclos.click();
|
||||
await expect(page.locator('.enclos-view')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Activer baffeur avec niveau bas (100)
|
||||
await page.click('.gauge-btn[data-gid="baffeur"]');
|
||||
const gaugeInput = page.locator('.gauge-inp[data-gid="baffeur"]');
|
||||
await gaugeInput.click();
|
||||
await gaugeInput.fill('100');
|
||||
await gaugeInput.press('Enter');
|
||||
|
||||
// L'enclos contient deja 1 DD (creee avec l'enclos)
|
||||
await expect(page.locator('.dd-grid .dd-card')).toHaveCount(1, { timeout: 5000 });
|
||||
|
||||
// Demarrer le timer
|
||||
await page.click('.enc-start-btn');
|
||||
await expect(page.locator('.enc-btn-pause')).toBeVisible({ timeout: 3000 });
|
||||
});
|
||||
|
||||
test('Recharger la jauge met a jour "Alarme dans"', async ({ page }) => {
|
||||
// Avec une jauge a 100, elle se vide rapidement
|
||||
// Attendre un peu pour que la jauge commence a se drainer
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Recharger la jauge a 50000
|
||||
const gaugeInput = page.locator('.gauge-inp[data-gid="baffeur"]');
|
||||
await gaugeInput.click();
|
||||
await gaugeInput.fill('50000');
|
||||
await gaugeInput.press('Enter');
|
||||
|
||||
// Attendre la mise a jour du DOM
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// "Alarme dans" devrait montrer un vrai temps (pas infini)
|
||||
const alarmText = await page.locator('.enc-alarm').textContent();
|
||||
expect(alarmText).not.toBe('\u221e');
|
||||
expect(alarmText).not.toBe('--:--:--');
|
||||
});
|
||||
|
||||
test('La barre de jauge se met a jour apres la recharge', async ({ page }) => {
|
||||
// Capturer la largeur de la barre avant la recharge
|
||||
const barBefore = await page.locator('.enc-gauge-bar-fill').first().evaluate(
|
||||
(el: HTMLElement) => parseFloat(el.style.width)
|
||||
);
|
||||
|
||||
// Recharger a 80000
|
||||
const gaugeInput = page.locator('.gauge-inp[data-gid="baffeur"]');
|
||||
await gaugeInput.click();
|
||||
await gaugeInput.fill('80000');
|
||||
await gaugeInput.press('Enter');
|
||||
|
||||
// Attendre la mise a jour
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// La barre devrait etre plus large maintenant
|
||||
const barAfter = await page.locator('.enc-gauge-bar-fill').first().evaluate(
|
||||
(el: HTMLElement) => parseFloat(el.style.width)
|
||||
);
|
||||
|
||||
expect(barAfter).toBeGreaterThan(barBefore);
|
||||
});
|
||||
});
|
||||
54
tests/e2e/navigation.spec.ts
Normal file
54
tests/e2e/navigation.spec.ts
Normal file
@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Tests E2E — Navigation dans la sidebar
|
||||
*
|
||||
* Verifie que chaque element de la sidebar affiche la bonne vue.
|
||||
*/
|
||||
import { test, expect } from './electron-app';
|
||||
|
||||
test.describe('Navigation sidebar', () => {
|
||||
test('Cliquer sur "Tableau de bord" affiche le dashboard', async ({ page }) => {
|
||||
await page.click('.sb-item[data-view="dashboard"]');
|
||||
// Le dashboard est la vue par defaut, verifions que le contenu est affiche
|
||||
await expect(page.locator('#enclos-content')).not.toBeEmpty();
|
||||
});
|
||||
|
||||
test('Cliquer sur "Statistiques" affiche la vue statistiques', async ({ page }) => {
|
||||
await page.click('.sb-item[data-view="statistiques"]');
|
||||
await expect(page.locator('#enclos-content')).not.toBeEmpty();
|
||||
});
|
||||
|
||||
test('Cliquer sur un enclos affiche la vue enclos', async ({ page }) => {
|
||||
// Le premier enclos a un data-view numerique (son id)
|
||||
const firstEnclos = page.locator('.sb-item[data-view]').filter({
|
||||
has: page.locator('.sb-dot'),
|
||||
}).first();
|
||||
await firstEnclos.click();
|
||||
// La vue enclos contient le conteneur .enclos-view
|
||||
await expect(page.locator('.enclos-view')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('Cliquer sur "Accouplement" affiche la vue accouplement', async ({ page }) => {
|
||||
await page.click('.sb-item[data-view="accouplement"]');
|
||||
await expect(page.locator('.accoup-view')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('Cliquer sur "Reappro" affiche la vue reappro', async ({ page }) => {
|
||||
await page.click('.sb-item[data-view="appro"]');
|
||||
await expect(page.locator('#enclos-content')).not.toBeEmpty();
|
||||
});
|
||||
|
||||
test('Cliquer sur "Inventaire" affiche la vue inventaire', async ({ page }) => {
|
||||
await page.click('.sb-item[data-view="inventaire"]');
|
||||
await expect(page.locator('#enclos-content')).not.toBeEmpty();
|
||||
});
|
||||
|
||||
test('Cliquer sur "Workflows" affiche la vue workflows', async ({ page }) => {
|
||||
await page.click('.sb-item[data-view="workflows"]');
|
||||
await expect(page.locator('#enclos-content')).not.toBeEmpty();
|
||||
});
|
||||
|
||||
test('Cliquer sur "Parametres" affiche la vue parametres', async ({ page }) => {
|
||||
await page.click('.sb-item[data-view="parametres"]');
|
||||
await expect(page.locator('#enclos-content')).not.toBeEmpty();
|
||||
});
|
||||
});
|
||||
107
tests/e2e/persistence.spec.ts
Normal file
107
tests/e2e/persistence.spec.ts
Normal file
@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Tests E2E — Persistance des donnees
|
||||
*
|
||||
* Verifie que les donnees (nom de DD, etc.) survivent
|
||||
* a la fermeture et reouverture de l'application.
|
||||
*/
|
||||
import { test as base, expect } from '@playwright/test';
|
||||
import { _electron as electron } from '@playwright/test';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const MAIN_PATH = path.resolve(__dirname, '../../dist-electron/main.js');
|
||||
const USER_DATA_DIR = path.resolve(__dirname, '../../.e2e-userdata-persistence');
|
||||
|
||||
function cleanDir(dir: string) {
|
||||
try {
|
||||
if (fs.existsSync(dir)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
} catch {
|
||||
// Ignore EPERM on Windows — le dossier sera ecrase au prochain lancement
|
||||
}
|
||||
}
|
||||
|
||||
// Fixture speciale qui permet de relancer l'app
|
||||
const test = base.extend<{}>({});
|
||||
|
||||
test.describe('Persistance des donnees', () => {
|
||||
// Nettoyer les donnees avant le test
|
||||
test.beforeAll(() => {
|
||||
cleanDir(USER_DATA_DIR);
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
cleanDir(USER_DATA_DIR);
|
||||
});
|
||||
|
||||
test('Le nom d\'une dragodinde persiste apres fermeture et reouverture', async () => {
|
||||
// --- Session 1 : creer un enclos, nommer la DD ---
|
||||
const app1 = await electron.launch({
|
||||
args: [MAIN_PATH],
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_USER_DATA_DIR: USER_DATA_DIR,
|
||||
},
|
||||
});
|
||||
|
||||
const page1 = await app1.firstWindow();
|
||||
await page1.waitForLoadState('domcontentloaded');
|
||||
await page1.waitForSelector('.app-shell', { timeout: 15000 });
|
||||
|
||||
// L'app demarre sans enclos — en creer un (il contient deja 1 DD)
|
||||
await page1.click('#add-enclos-btn');
|
||||
await page1.waitForSelector('.sb-item .sb-dot', { timeout: 5000 });
|
||||
|
||||
// Naviguer vers le premier enclos
|
||||
const firstEnclos1 = page1.locator('.sb-item[data-view]').filter({
|
||||
has: page1.locator('.sb-dot'),
|
||||
}).first();
|
||||
await firstEnclos1.click();
|
||||
await expect(page1.locator('.enclos-view')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// L'enclos contient deja 1 DD (creee avec l'enclos)
|
||||
await expect(page1.locator('.dd-grid .dd-card')).toHaveCount(1, { timeout: 5000 });
|
||||
|
||||
// Renommer la DD (trouver l'input de nom dans la carte DD)
|
||||
const ddNameInput = page1.locator('.dd-card .dd-name').first();
|
||||
await ddNameInput.click();
|
||||
await ddNameInput.fill('TestPersist42');
|
||||
await ddNameInput.press('Enter');
|
||||
|
||||
// Attendre que la sauvegarde soit effectuee
|
||||
await page1.waitForTimeout(1000);
|
||||
|
||||
// Fermer l'app
|
||||
await app1.close();
|
||||
|
||||
// --- Session 2 : verifier que la DD est toujours la ---
|
||||
const app2 = await electron.launch({
|
||||
args: [MAIN_PATH],
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_USER_DATA_DIR: USER_DATA_DIR,
|
||||
},
|
||||
});
|
||||
|
||||
const page2 = await app2.firstWindow();
|
||||
await page2.waitForLoadState('domcontentloaded');
|
||||
await page2.waitForSelector('.app-shell', { timeout: 15000 });
|
||||
|
||||
// Naviguer vers le premier enclos
|
||||
const firstEnclos2 = page2.locator('.sb-item[data-view]').filter({
|
||||
has: page2.locator('.sb-dot'),
|
||||
}).first();
|
||||
await firstEnclos2.click();
|
||||
await expect(page2.locator('.enclos-view')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Verifier que la DD est toujours presente
|
||||
await expect(page2.locator('.dd-grid .dd-card')).toHaveCount(1, { timeout: 5000 });
|
||||
|
||||
// Verifier que le nom persiste
|
||||
const ddNameInput2 = page2.locator('.dd-card .dd-name').first();
|
||||
await expect(ddNameInput2).toHaveValue('TestPersist42', { timeout: 5000 });
|
||||
|
||||
await app2.close();
|
||||
});
|
||||
});
|
||||
113
tests/e2e/timer-workflow.spec.ts
Normal file
113
tests/e2e/timer-workflow.spec.ts
Normal file
@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Tests E2E — Cycle de vie complet du timer
|
||||
*
|
||||
* Navigue vers un enclos, active une jauge, ajoute une DD,
|
||||
* demarre/pause/reprend/reset le timer.
|
||||
*/
|
||||
import { test, expect } from './electron-app';
|
||||
|
||||
test.describe('Cycle de vie du timer', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Naviguer vers le premier enclos via la sidebar
|
||||
const firstEnclos = page.locator('.sb-item[data-view]').filter({
|
||||
has: page.locator('.sb-dot'),
|
||||
}).first();
|
||||
await firstEnclos.click();
|
||||
await expect(page.locator('.enclos-view')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('Activer la jauge baffeur et definir son niveau', async ({ page }) => {
|
||||
// Cliquer sur le bouton baffeur pour l'activer
|
||||
await page.click('.gauge-btn[data-gid="baffeur"]');
|
||||
// Verifier que la jauge est active (classe .on)
|
||||
await expect(page.locator('.gauge-btn[data-gid="baffeur"].on')).toBeVisible();
|
||||
|
||||
// Definir le niveau de la jauge a 50000
|
||||
const gaugeInput = page.locator('.gauge-inp[data-gid="baffeur"]');
|
||||
await gaugeInput.click();
|
||||
await gaugeInput.fill('50000');
|
||||
await gaugeInput.press('Enter');
|
||||
// Verifier que la valeur est bien 50000
|
||||
await expect(gaugeInput).toHaveValue('50000');
|
||||
});
|
||||
|
||||
test('Ajouter une dragodinde puis demarrer le timer', async ({ page }) => {
|
||||
// Activer la jauge baffeur
|
||||
await page.click('.gauge-btn[data-gid="baffeur"]');
|
||||
const gaugeInput = page.locator('.gauge-inp[data-gid="baffeur"]');
|
||||
await gaugeInput.click();
|
||||
await gaugeInput.fill('50000');
|
||||
await gaugeInput.press('Enter');
|
||||
|
||||
// L'enclos contient deja 1 DD (creee avec l'enclos)
|
||||
const ddGrid = page.locator('.dd-grid');
|
||||
await expect(ddGrid.locator('.dd-card')).toHaveCount(1, { timeout: 5000 });
|
||||
|
||||
// Demarrer le timer
|
||||
const startBtn = page.locator('.enc-start-btn');
|
||||
await startBtn.click();
|
||||
|
||||
// Verifier que le bouton est passe en mode PAUSE
|
||||
await expect(page.locator('.enc-btn-pause')).toBeVisible({ timeout: 3000 });
|
||||
|
||||
// Verifier que le temps ecoule n'est plus 00:00:00 (apres un petit delai)
|
||||
await page.waitForTimeout(1500);
|
||||
const elapsedText = await page.locator('.enc-elapsed').textContent();
|
||||
expect(elapsedText).not.toBe('00:00:00');
|
||||
|
||||
// Verifier que "Alarme dans" montre un temps (pas --:--:-- ni infini)
|
||||
const alarmText = await page.locator('.enc-alarm').textContent();
|
||||
expect(alarmText).not.toBe('--:--:--');
|
||||
expect(alarmText).not.toBe('\u221e');
|
||||
});
|
||||
|
||||
test('Pause et reprise du timer', async ({ page }) => {
|
||||
// Setup: activer jauge + DD + demarrer
|
||||
await page.click('.gauge-btn[data-gid="baffeur"]');
|
||||
const gaugeInput = page.locator('.gauge-inp[data-gid="baffeur"]');
|
||||
await gaugeInput.click();
|
||||
await gaugeInput.fill('50000');
|
||||
await gaugeInput.press('Enter');
|
||||
await page.click('button:has-text("Ajouter une Dragodinde")');
|
||||
await page.click('.enc-start-btn');
|
||||
await expect(page.locator('.enc-btn-pause')).toBeVisible({ timeout: 3000 });
|
||||
|
||||
// Mettre en pause
|
||||
await page.click('.enc-btn-pause');
|
||||
// Le bouton devrait afficher REPRENDRE
|
||||
await expect(page.locator('.enc-start-btn')).toContainText('REPRENDRE', { timeout: 3000 });
|
||||
|
||||
// Capturer le temps ecoule apres la pause
|
||||
const elapsedAfterPause = await page.locator('.enc-elapsed').textContent();
|
||||
|
||||
// Attendre un moment pour verifier que le temps ne bouge pas
|
||||
await page.waitForTimeout(1500);
|
||||
const elapsedStillPaused = await page.locator('.enc-elapsed').textContent();
|
||||
expect(elapsedStillPaused).toBe(elapsedAfterPause);
|
||||
|
||||
// Reprendre le timer
|
||||
await page.click('.enc-start-btn');
|
||||
await expect(page.locator('.enc-btn-pause')).toBeVisible({ timeout: 3000 });
|
||||
});
|
||||
|
||||
test('Reset du timer', async ({ page }) => {
|
||||
// Setup: activer jauge + DD + demarrer
|
||||
await page.click('.gauge-btn[data-gid="baffeur"]');
|
||||
const gaugeInput = page.locator('.gauge-inp[data-gid="baffeur"]');
|
||||
await gaugeInput.click();
|
||||
await gaugeInput.fill('50000');
|
||||
await gaugeInput.press('Enter');
|
||||
await page.click('button:has-text("Ajouter une Dragodinde")');
|
||||
await page.click('.enc-start-btn');
|
||||
await expect(page.locator('.enc-btn-pause')).toBeVisible({ timeout: 3000 });
|
||||
|
||||
// Cliquer sur le bouton reset
|
||||
await page.click('.enc-reset-btn');
|
||||
|
||||
// Verifier que le timer est revenu a 00:00:00
|
||||
await expect(page.locator('.enc-elapsed')).toHaveText('00:00:00', { timeout: 3000 });
|
||||
|
||||
// Le bouton devrait afficher DEMARRER
|
||||
await expect(page.locator('.enc-start-btn')).toContainText('MARRER', { timeout: 3000 });
|
||||
});
|
||||
});
|
||||
76
tests/functional/breeding-workflow.test.ts
Normal file
76
tests/functional/breeding-workflow.test.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { CommandBus } from '@application/handlers/CommandBus';
|
||||
import { QueryBus } from '@application/handlers/QueryBus';
|
||||
import { EventBus } from '@domain/events/EventBus';
|
||||
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
|
||||
import { createRegisterAccouplementHandler } from '@application/commands/RegisterAccouplement';
|
||||
import { createGetDashboardHandler } from '@application/queries/GetDashboard';
|
||||
import { createGetBreedingOptionsHandler } from '@application/queries/GetBreedingOptions';
|
||||
import { BreedingService } from '@domain/services/BreedingService';
|
||||
|
||||
function setup() {
|
||||
const repo: StateRepository = { load: vi.fn(async () => null), save: vi.fn() };
|
||||
const events = new EventBus();
|
||||
const state: AppState = {
|
||||
enclos: [], activeId: 'dashboard', nextEnclosId: 1,
|
||||
alarmSound: 'arpege', notifsEnabled: true, ntfyTopic: '',
|
||||
archivedStats: [], inventaire: {}, workflows: [], accouplements: [],
|
||||
};
|
||||
|
||||
const cmdBus = new CommandBus();
|
||||
const qBus = new QueryBus();
|
||||
|
||||
cmdBus.register('register-accouplement', createRegisterAccouplementHandler(state, repo, events));
|
||||
qBus.register('get-dashboard', createGetDashboardHandler(state));
|
||||
qBus.register('get-breeding-options', createGetBreedingOptionsHandler());
|
||||
|
||||
return { state, cmdBus, qBus };
|
||||
}
|
||||
|
||||
describe('Breeding Workflow', () => {
|
||||
it('select parents → deduce baby → register → dashboard reflects', () => {
|
||||
const { cmdBus, qBus } = setup();
|
||||
const svc = new BreedingService();
|
||||
|
||||
// 1. Get compatible partners for Rousse
|
||||
const options = qBus.execute<any>({ type: 'get-breeding-options', race: 'Rousse' });
|
||||
expect(options.partners.some((p: any) => p.partner === 'Dorée')).toBe(true);
|
||||
|
||||
// 2. Deduce baby
|
||||
const baby = svc.deduceBaby('Rousse', 'Dorée');
|
||||
expect(baby).toBe('Dorée et Rousse');
|
||||
|
||||
// 3. Register accouplement
|
||||
cmdBus.execute({
|
||||
type: 'register-accouplement',
|
||||
parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse',
|
||||
couples: 10, babiesObtained: 7,
|
||||
});
|
||||
|
||||
// 4. Dashboard shows updated stats
|
||||
const dash = qBus.execute<any>({ type: 'get-dashboard' });
|
||||
expect(dash.totalCouples).toBe(10);
|
||||
expect(dash.totalBabies).toBe(7);
|
||||
expect(dash.successRate).toBe(70);
|
||||
expect(dash.raceBreakdown['Dorée et Rousse']).toBe(7);
|
||||
});
|
||||
|
||||
it('multiple accouplements accumulate', () => {
|
||||
const { cmdBus, qBus } = setup();
|
||||
|
||||
cmdBus.execute({
|
||||
type: 'register-accouplement',
|
||||
parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse',
|
||||
couples: 5, babiesObtained: 3,
|
||||
});
|
||||
cmdBus.execute({
|
||||
type: 'register-accouplement',
|
||||
parentA: 'Amande', parentB: 'Dorée', baby: 'Amande et Dorée',
|
||||
couples: 8, babiesObtained: 5,
|
||||
});
|
||||
|
||||
const dash = qBus.execute<any>({ type: 'get-dashboard' });
|
||||
expect(dash.totalCouples).toBe(13);
|
||||
expect(dash.totalBabies).toBe(8);
|
||||
});
|
||||
});
|
||||
67
tests/functional/enclos-management.test.ts
Normal file
67
tests/functional/enclos-management.test.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { CommandBus } from '@application/handlers/CommandBus';
|
||||
import { EventBus } from '@domain/events/EventBus';
|
||||
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
|
||||
import { createCreateEnclosHandler } from '@application/commands/CreateEnclos';
|
||||
import { createDeleteEnclosHandler } from '@application/commands/DeleteEnclos';
|
||||
import { createAddDragodindeHandler } from '@application/commands/AddDragodinde';
|
||||
import { createRemoveDragodindeHandler } from '@application/commands/RemoveDragodinde';
|
||||
import { MAX_ENCLOS, MAX_DD } from '@domain/entities/Enclos';
|
||||
|
||||
function setup() {
|
||||
const repo: StateRepository = { load: vi.fn(async () => null), save: vi.fn() };
|
||||
const events = new EventBus();
|
||||
const state: AppState = {
|
||||
enclos: [], activeId: 'dashboard', nextEnclosId: 1,
|
||||
alarmSound: 'arpege', notifsEnabled: true, ntfyTopic: '',
|
||||
archivedStats: [], inventaire: {}, workflows: [], accouplements: [],
|
||||
};
|
||||
|
||||
const cmdBus = new CommandBus();
|
||||
cmdBus.register('create-enclos', createCreateEnclosHandler(state, repo));
|
||||
cmdBus.register('delete-enclos', createDeleteEnclosHandler(state, repo, events));
|
||||
cmdBus.register('add-dragodinde', createAddDragodindeHandler(state, repo));
|
||||
cmdBus.register('remove-dragodinde', createRemoveDragodindeHandler(state, repo));
|
||||
|
||||
return { state, cmdBus, events };
|
||||
}
|
||||
|
||||
describe('Enclos Management', () => {
|
||||
it('create up to MAX_ENCLOS', () => {
|
||||
const { state, cmdBus } = setup();
|
||||
for (let i = 0; i < MAX_ENCLOS + 2; i++) {
|
||||
cmdBus.execute({ type: 'create-enclos' });
|
||||
}
|
||||
expect(state.enclos).toHaveLength(MAX_ENCLOS);
|
||||
});
|
||||
|
||||
it('delete switches to dashboard when last enclos removed', () => {
|
||||
const { state, cmdBus } = setup();
|
||||
cmdBus.execute({ type: 'create-enclos' });
|
||||
const id = state.enclos[0]!.id;
|
||||
state.activeId = id;
|
||||
cmdBus.execute({ type: 'delete-enclos', enclosId: id });
|
||||
expect(state.enclos).toHaveLength(0);
|
||||
expect(state.activeId).toBe('dashboard');
|
||||
});
|
||||
|
||||
it('add DDs up to MAX_DD per enclos', () => {
|
||||
const { state, cmdBus } = setup();
|
||||
cmdBus.execute({ type: 'create-enclos' });
|
||||
const encId = state.enclos[0]!.id;
|
||||
// Already has 1 DD from creation
|
||||
for (let i = 0; i < MAX_DD; i++) {
|
||||
cmdBus.execute({ type: 'add-dragodinde', enclosId: encId });
|
||||
}
|
||||
expect(state.enclos[0]!.dragodindes.length).toBeLessThanOrEqual(MAX_DD);
|
||||
});
|
||||
|
||||
it('remove DD from enclos', () => {
|
||||
const { state, cmdBus } = setup();
|
||||
cmdBus.execute({ type: 'create-enclos' });
|
||||
const encId = state.enclos[0]!.id;
|
||||
const ddId = state.enclos[0]!.dragodindes[0]!.id;
|
||||
cmdBus.execute({ type: 'remove-dragodinde', enclosId: encId, ddId });
|
||||
expect(state.enclos[0]!.dragodindes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
76
tests/functional/timer-workflow.test.ts
Normal file
76
tests/functional/timer-workflow.test.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { CommandBus } from '@application/handlers/CommandBus';
|
||||
import { QueryBus } from '@application/handlers/QueryBus';
|
||||
import { EventBus } from '@domain/events/EventBus';
|
||||
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
|
||||
import { createStartTimerHandler } from '@application/commands/StartTimer';
|
||||
import { createStopTimerHandler } from '@application/commands/StopTimer';
|
||||
import { createCreateEnclosHandler } from '@application/commands/CreateEnclos';
|
||||
import { createAddDragodindeHandler } from '@application/commands/AddDragodinde';
|
||||
import { createToggleGaugeHandler } from '@application/commands/UpdateGauge';
|
||||
import { createGetTimerStateHandler } from '@application/queries/GetTimerState';
|
||||
import { createGetDashboardHandler } from '@application/queries/GetDashboard';
|
||||
|
||||
function setup() {
|
||||
const repo: StateRepository = { load: vi.fn(async () => null), save: vi.fn() };
|
||||
const events = new EventBus();
|
||||
const state: AppState = {
|
||||
enclos: [], activeId: 'dashboard', nextEnclosId: 1,
|
||||
alarmSound: 'arpege', notifsEnabled: true, ntfyTopic: '',
|
||||
archivedStats: [], inventaire: {}, workflows: [], accouplements: [],
|
||||
};
|
||||
|
||||
const cmdBus = new CommandBus();
|
||||
const qBus = new QueryBus();
|
||||
|
||||
cmdBus.register('create-enclos', createCreateEnclosHandler(state, repo));
|
||||
cmdBus.register('add-dragodinde', createAddDragodindeHandler(state, repo));
|
||||
cmdBus.register('toggle-gauge', createToggleGaugeHandler(state, repo));
|
||||
cmdBus.register('start-timer', createStartTimerHandler(state, repo));
|
||||
cmdBus.register('stop-timer', createStopTimerHandler(state, repo));
|
||||
|
||||
qBus.register('get-timer-state', createGetTimerStateHandler(state));
|
||||
qBus.register('get-dashboard', createGetDashboardHandler(state));
|
||||
|
||||
return { state, repo, events, cmdBus, qBus };
|
||||
}
|
||||
|
||||
describe('Timer Workflow', () => {
|
||||
it('full lifecycle: create → configure → start → stop', () => {
|
||||
const { state, cmdBus, qBus } = setup();
|
||||
|
||||
// 1. Create enclos (starts with 1 DD automatically)
|
||||
cmdBus.execute({ type: 'create-enclos' });
|
||||
expect(state.enclos).toHaveLength(1);
|
||||
expect(state.enclos[0]!.dragodindes).toHaveLength(1);
|
||||
|
||||
const encId = state.enclos[0]!.id;
|
||||
|
||||
// 2. Toggle gauge
|
||||
cmdBus.execute({ type: 'toggle-gauge', enclosId: encId, gaugeId: 'baffeur' });
|
||||
expect(state.enclos[0]!.activeGauges).toContain('baffeur');
|
||||
|
||||
// 3. Set gauge level (modify state directly since we don't have that command wired)
|
||||
state.enclos[0]!.gaugeLevels.baffeur = 50000;
|
||||
|
||||
// 4. Start timer
|
||||
cmdBus.execute({ type: 'start-timer', enclosId: encId });
|
||||
const timerState = qBus.execute<any>({ type: 'get-timer-state', enclosId: encId });
|
||||
expect(timerState.running).toBe(true);
|
||||
|
||||
// 5. Stop timer
|
||||
cmdBus.execute({ type: 'stop-timer', enclosId: encId });
|
||||
const stopped = qBus.execute<any>({ type: 'get-timer-state', enclosId: encId });
|
||||
expect(stopped.running).toBe(false);
|
||||
});
|
||||
|
||||
it('dashboard reflects enclos state', () => {
|
||||
const { cmdBus, qBus } = setup();
|
||||
|
||||
cmdBus.execute({ type: 'create-enclos' });
|
||||
cmdBus.execute({ type: 'create-enclos' });
|
||||
|
||||
const dash = qBus.execute<any>({ type: 'get-dashboard' });
|
||||
expect(dash.enclosSummaries).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
27
tests/regression/breeding-deduction.test.ts
Normal file
27
tests/regression/breeding-deduction.test.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { BreedingService } from '@domain/services/BreedingService';
|
||||
import { BREEDING_RECIPES } from '@domain/value-objects/Race';
|
||||
import { createAccouplement } from '@domain/entities/Accouplement';
|
||||
|
||||
describe('Regression: Breeding deduction', () => {
|
||||
const svc = new BreedingService();
|
||||
|
||||
it('bidirectional parent order produces same baby', () => {
|
||||
// For ALL recipes, both orderings must work
|
||||
for (const [baby, [pA, pB]] of Object.entries(BREEDING_RECIPES)) {
|
||||
const resultAB = svc.deduceBaby(pA, pB);
|
||||
const resultBA = svc.deduceBaby(pB, pA);
|
||||
expect(resultAB).toBe(baby);
|
||||
expect(resultBA).toBe(baby);
|
||||
}
|
||||
});
|
||||
|
||||
it('babies from breeding are gender-neutral', () => {
|
||||
// Bug v1.1.4: babies were assigned male/female arbitrarily instead of neutral
|
||||
// Verify domain doesn't assign gender in Accouplement
|
||||
const acc = createAccouplement('Rousse', 'Dorée', 'Dorée et Rousse', 2, 5, 3);
|
||||
// Accouplement doesn't track baby gender — babies are neutral by design
|
||||
expect(acc.baby).toBe('Dorée et Rousse');
|
||||
// No gender field on Accouplement — that's correct
|
||||
});
|
||||
});
|
||||
106
tests/regression/gauge-recharge-countdown.test.ts
Normal file
106
tests/regression/gauge-recharge-countdown.test.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { computeGaugeLive, enclosGlobalState } from '@presentation/helpers/gauge-live';
|
||||
import { createEnclos } from '@domain/entities/Enclos';
|
||||
import { createDragodinde } from '@domain/entities/Dragodinde';
|
||||
import type { Enclos } from '@domain/entities/Enclos';
|
||||
import type { Dragodinde } from '@domain/entities/Dragodinde';
|
||||
|
||||
/**
|
||||
* Régression : après une recharge de jauge en cours de timer,
|
||||
* le countdown (cntDown) doit être fini (pas Infinity).
|
||||
*
|
||||
* Scénario reproduit : la jauge baffeur se vide complètement,
|
||||
* le joueur la recharge → le countdown doit recalculer à partir
|
||||
* du nouveau niveau de jauge, pas rester bloqué à ∞.
|
||||
*/
|
||||
describe('Régression: countdown fini après recharge de jauge vidée', () => {
|
||||
const NOW = 1_700_000_000_000; // timestamp fixe
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function buildEnclos(dd: Dragodinde, opts: {
|
||||
startGl: number;
|
||||
elapsedSec: number;
|
||||
recharges?: { atSec: number; level: number }[];
|
||||
currentGl: number;
|
||||
}): Enclos {
|
||||
const enc = createEnclos(1, 'Test');
|
||||
enc.activeGauges = ['baffeur'];
|
||||
enc.gaugeLevels = { ...enc.gaugeLevels, baffeur: opts.currentGl };
|
||||
enc.dragodindes = [dd];
|
||||
enc.timer = {
|
||||
running: true,
|
||||
startTime: NOW - opts.elapsedSec * 1000,
|
||||
pausedAt: null,
|
||||
pausedMs: 0,
|
||||
snapGauges: { baffeur: opts.startGl },
|
||||
snapStats: { [dd.id]: { serenite: 0, endurance: 0, maturite: 0, amour: 0, xp: 1 } },
|
||||
gaugeRecharges: opts.recharges
|
||||
? { baffeur: opts.recharges }
|
||||
: {},
|
||||
};
|
||||
return enc;
|
||||
}
|
||||
|
||||
it('sans recharge et jauge vidée → cntDown est Infinity', () => {
|
||||
const dd = createDragodinde(1);
|
||||
// Jauge baffeur départ = 100, elapsed = 200s → largement vidée (100 pts = tier1 10/tick)
|
||||
// timeToGain(100, 100) = ceil(100/10)*10 = 100s → à 200s la jauge est à 0
|
||||
const enc = buildEnclos(dd, {
|
||||
startGl: 100,
|
||||
elapsedSec: 200,
|
||||
currentGl: 0,
|
||||
});
|
||||
|
||||
const result = computeGaugeLive(enc, dd, 'baffeur', 200, true);
|
||||
|
||||
expect(result.curGl).toBe(0);
|
||||
expect(result.cntDown).toBe(Infinity);
|
||||
});
|
||||
|
||||
it('après recharge de la jauge vidée → cntDown est fini et > 0', () => {
|
||||
const dd = createDragodinde(1);
|
||||
// Jauge baffeur départ = 100, elapsed = 200s
|
||||
// Recharge à t=150s avec niveau 50000
|
||||
// À t=200s, la jauge a été rechargée et n'est plus à 0
|
||||
const enc = buildEnclos(dd, {
|
||||
startGl: 100,
|
||||
elapsedSec: 200,
|
||||
recharges: [{ atSec: 150, level: 50000 }],
|
||||
currentGl: 50000,
|
||||
});
|
||||
|
||||
const result = computeGaugeLive(enc, dd, 'baffeur', 200, true);
|
||||
|
||||
// La jauge rechargée à 50000 a eu 50s pour se vider (200-150)
|
||||
// Elle doit être > 0 (50000 pts se vide en bien plus de 50s)
|
||||
expect(result.curGl).toBeGreaterThan(0);
|
||||
|
||||
// Le countdown doit être fini (pas Infinity) et positif
|
||||
expect(isFinite(result.cntDown)).toBe(true);
|
||||
expect(result.cntDown).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('enclosGlobalState retourne un globalMax fini après recharge', () => {
|
||||
const dd = createDragodinde(1);
|
||||
const enc = buildEnclos(dd, {
|
||||
startGl: 100,
|
||||
elapsedSec: 200,
|
||||
recharges: [{ atSec: 150, level: 50000 }],
|
||||
currentGl: 50000,
|
||||
});
|
||||
|
||||
const state = enclosGlobalState(enc);
|
||||
|
||||
expect(state.started).toBe(true);
|
||||
expect(isFinite(state.globalMax)).toBe(true);
|
||||
expect(state.globalMax).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
142
tests/regression/gauge-recharge.test.ts
Normal file
142
tests/regression/gauge-recharge.test.ts
Normal file
@ -0,0 +1,142 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { computeGaugeState } from '@domain/services/GaugeCalculator';
|
||||
import type { GaugeRecharge } from '@domain/services/GaugeCalculator';
|
||||
import { gainedIn, timeToGain } from '@domain/services/GaugeCalculator';
|
||||
import { xpForLevel, levelFromXp } from '@domain/value-objects/XpTable';
|
||||
|
||||
/**
|
||||
* Tests de régression pour la fonctionnalité de recharge de jauge en cours de session.
|
||||
*
|
||||
* Scénario typique : le joueur recharge la mangeoire pendant le timer car elle s'est vidée.
|
||||
* Sans recharge prise en compte, le countdown reste bloqué à ∞ ou 0 au lieu de recalculer.
|
||||
*/
|
||||
describe('Régression: recharge de jauge en cours de timer', () => {
|
||||
|
||||
describe('Mangeoire rechargée une fois (scénario XP principal)', () => {
|
||||
// Contexte :
|
||||
// - Mangeoire départ = 50 000 (tier 2, rate 20/tick)
|
||||
// - DD level 1, cible level 50
|
||||
// - Après 1h (3600 sec), la mangeoire est vide → XP gagnée jusqu'ici
|
||||
// - Le joueur recharge à 80 000 (tier 3)
|
||||
// - On simule 1h supplémentaire après recharge
|
||||
|
||||
const startGl = 50000;
|
||||
const xpCible = xpForLevel(50) - xpForLevel(1); // 34 365 XP
|
||||
|
||||
it('sans recharge : XP limité par la jauge qui se vide', () => {
|
||||
// 50 000 pts de jauge → max 50 000 XP disponible (tier 2 → tier 1)
|
||||
// Mais XP cible = 34 365 → la jauge suffira-t-elle ?
|
||||
// timeToGain(50000, 34365) :
|
||||
// tier2 (40000-50000) : 10000 pts, s += ceil(10000/20)*10=5000, rem=24365
|
||||
// tier1 (0-40000) : 40000 pts, s += ceil(24365/10)*10=24370, rem=0
|
||||
// total = 29370 sec ≈ 8h 9m
|
||||
const sec = timeToGain(startGl, xpCible);
|
||||
expect(sec).toBe(29370);
|
||||
});
|
||||
|
||||
it('avec recharge à t=5000 sec : XP total = seg1 + seg2', () => {
|
||||
// Seg1 : 50000 (tier2→tier1), 5000 sec → gainedIn(50000, 5000) = ?
|
||||
// tier2 (40000-50000) : a=10000, m=500, u=500 ticks → 10000 pts, tl=0
|
||||
// → gained seg1 = 10000
|
||||
// Recharge à 80000 à t=5000
|
||||
// Seg2 : 80000 (tier3), 5000 sec → gainedIn(80000, 5000) = ?
|
||||
// tier3 (70000-80000) : a=10000, m=333, u=333 ticks → 9990, tl=167
|
||||
// tier2 (40000-70000) : a=30000, m=1500, u=167 ticks → 3340
|
||||
// → gained seg2 = 13330
|
||||
// Total = 23330 XP
|
||||
const recharges: GaugeRecharge[] = [{ atSec: 5000, level: 80000 }];
|
||||
const { gained } = computeGaugeState(startGl, recharges, Infinity, 10000);
|
||||
const expectedSeg1 = gainedIn(50000, 5000);
|
||||
const expectedSeg2 = gainedIn(80000, 5000);
|
||||
expect(gained).toBe(expectedSeg1 + expectedSeg2);
|
||||
});
|
||||
|
||||
it('la recharge fait redémarrer le countdown vers la cible', () => {
|
||||
// Après recharge : XP restante = xpCible - gained_so_far
|
||||
// Le countdown doit utiliser curGl (niveau après recharge et dépletion seg2)
|
||||
const rechargeEl = 5000; // 5000 sec = quand le joueur recharge
|
||||
const totalEl = 10000;
|
||||
const recharges: GaugeRecharge[] = [{ atSec: rechargeEl, level: 80000 }];
|
||||
|
||||
const { gained: gainedSoFar, curGl } = computeGaugeState(startGl, recharges, Infinity, totalEl);
|
||||
const xpRestante = Math.max(0, xpCible - gainedSoFar);
|
||||
|
||||
// curGl doit refléter la jauge après seg2 (pas celle d'origine à 50000)
|
||||
expect(curGl).toBeGreaterThan(0);
|
||||
expect(curGl).toBeLessThan(80000); // la jauge a déjà décru depuis la recharge
|
||||
|
||||
// timeToGain depuis curGl doit être < timeToGain depuis startGl (grâce à la recharge)
|
||||
const secAvecRecharge = timeToGain(curGl, xpRestante);
|
||||
const secSansRecharge = timeToGain(gainedIn(startGl, 5000) > 0 ? 0 : startGl, xpRestante);
|
||||
expect(secAvecRecharge).toBeLessThan(secSansRecharge === Infinity ? Infinity : secSansRecharge + 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Baffeur rechargé (sérenité)', () => {
|
||||
// Baffeur 70000 (tier2 → cap à -5000 séren.)
|
||||
// Séren. départ = 0, cap absolu = 5000 pts à gagner
|
||||
// À t=3000 sec, cap atteint → gel. Recharge à t=4000 → ne change rien (déjà gelé).
|
||||
|
||||
const startGl = 70000;
|
||||
const ptsToAbsCap = 5000; // 0 - (-5000)
|
||||
|
||||
it('gel au cap absolu même avec recharge après le gel', () => {
|
||||
const recharges: GaugeRecharge[] = [{ atSec: 4000, level: 90000 }]; // recharge après gel
|
||||
const { gained, effectiveEl } = computeGaugeState(startGl, recharges, ptsToAbsCap, 9999);
|
||||
|
||||
// Gel = timeToGain(70000, 5000) :
|
||||
// tier2 (40000-70000) : 5000 pts → ceil(5000/20)*10 = 2500 sec
|
||||
expect(gained).toBe(5000);
|
||||
expect(effectiveEl).toBe(2500);
|
||||
// La recharge à t=4000 est APRÈS le gel à t=2500 → ignorée
|
||||
});
|
||||
|
||||
it('recharge AVANT le gel allonge le temps de traitement', () => {
|
||||
// Recharge à t=1000 (avant gel naturel à t=2500)
|
||||
// Seg1 : 70000, 1000 sec → gainedIn(70000, 1000) = 100 ticks
|
||||
// tier3 (70000-90000): a=0 (70000 non > 70000... wait 70000>70000=false)
|
||||
// Hmm: 70000 > 70000 = false, donc tier3 skip
|
||||
// tier2 (40000-70000): a=30000, m=1500, u=min(1500,100)=100, out=2000
|
||||
// gained seg1 = 2000, jauge = 70000 - 100*20 = 68000
|
||||
// Recharge à 90000 à t=1000
|
||||
// Seg2 : 90000, besoin encore 3000 pts, timeToGain(90000,3000) :
|
||||
// tier3 (70000-90000): 20000 pts, 3000/30=100 ticks=1000 sec
|
||||
// effectiveEl = 1000 + 1000 = 2000
|
||||
const recharges: GaugeRecharge[] = [{ atSec: 1000, level: 90000 }];
|
||||
const { gained, effectiveEl } = computeGaugeState(70000, recharges, 5000, 9999);
|
||||
expect(gained).toBe(5000);
|
||||
expect(effectiveEl).toBe(2000); // plus rapide qu'à 2500 grâce au tier3
|
||||
});
|
||||
});
|
||||
|
||||
describe('Deux recharges successives (mangeoire longue session)', () => {
|
||||
it('accumule correctement trois segments', () => {
|
||||
// Seg1 : 30000 (tier1), 500 sec → 50 ticks × 10 = 500 pts
|
||||
// Recharge 1 à t=500 → 80000
|
||||
// Seg2 : 80000 (tier2→tier3), 500 sec → gainedIn(80000, 500)
|
||||
// Recharge 2 à t=1000 → 95000
|
||||
// Seg3 : 95000 (tier4), 500 sec → gainedIn(95000, 500)
|
||||
const recharges: GaugeRecharge[] = [
|
||||
{ atSec: 500, level: 80000 },
|
||||
{ atSec: 1000, level: 95000 },
|
||||
];
|
||||
const { gained } = computeGaugeState(30000, recharges, Infinity, 1500);
|
||||
const expected = gainedIn(30000, 500) + gainedIn(80000, 500) + gainedIn(95000, 500);
|
||||
expect(gained).toBe(expected);
|
||||
});
|
||||
|
||||
it('le niveau XP estimé est cohérent avec les points accumulés', () => {
|
||||
const startXp = 1; // level 1
|
||||
const startGl = 30000;
|
||||
const recharges: GaugeRecharge[] = [
|
||||
{ atSec: 500, level: 80000 },
|
||||
{ atSec: 1000, level: 95000 },
|
||||
];
|
||||
const { gained } = computeGaugeState(startGl, recharges, Infinity, 1500);
|
||||
const estLevel = levelFromXp(xpForLevel(startXp) + gained);
|
||||
// Avec 3 segments : ~500 + ~gainedIn(80000,500) + ~gainedIn(95000,500) pts d'XP
|
||||
// ça devrait faire progresser significativement le niveau
|
||||
expect(estLevel).toBeGreaterThan(startXp);
|
||||
});
|
||||
});
|
||||
});
|
||||
37
tests/regression/gauge-tier-calculation.test.ts
Normal file
37
tests/regression/gauge-tier-calculation.test.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { gainedIn, timeToGain, gaugeAfter } from '@domain/services/GaugeCalculator';
|
||||
|
||||
describe('Regression: Gauge tier calculations', () => {
|
||||
it('gaugeAfter is inverse of gainedIn', () => {
|
||||
// For a given level and time, gaugeAfter should equal level - gainedIn
|
||||
const level = 80000;
|
||||
const sec = 100; // 10 ticks
|
||||
const gained = gainedIn(level, sec);
|
||||
const after = gaugeAfter(level, sec);
|
||||
expect(after).toBe(level - gained);
|
||||
});
|
||||
|
||||
it('timeToGain matches gainedIn for exact amounts', () => {
|
||||
// If gainedIn(50000, 100) = X, then timeToGain(50000, X) should be <= 100
|
||||
const level = 50000;
|
||||
const sec = 100;
|
||||
const gained = gainedIn(level, sec);
|
||||
if (gained > 0) {
|
||||
const time = timeToGain(level, gained);
|
||||
expect(time).toBeLessThanOrEqual(sec);
|
||||
}
|
||||
});
|
||||
|
||||
it('boundary: level exactly at tier threshold', () => {
|
||||
// Level 40000 = tier 1 (<=40000), rate 10
|
||||
expect(gaugeAfter(40000, 10)).toBe(39990); // 1 tick at rate 10
|
||||
// Level 40001 = tier 2 (>40000), but only 1 pt above threshold
|
||||
// 1 pt in tier 2 zone: floor(1/20)=0 ticks consumed there, falls through to tier 1
|
||||
expect(gaugeAfter(40001, 10)).toBe(39991); // 1 tick at rate 10 in tier 1 zone
|
||||
});
|
||||
|
||||
it('does not produce negative values', () => {
|
||||
expect(gaugeAfter(10, 100000)).toBe(0);
|
||||
expect(gainedIn(10, 100000)).toBe(10);
|
||||
});
|
||||
});
|
||||
58
tests/regression/inventaire-simulation.test.ts
Normal file
58
tests/regression/inventaire-simulation.test.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { simulateStock } from '@domain/services/StockSimulator';
|
||||
import { BREEDING_RECIPES } from '@domain/value-objects/Race';
|
||||
|
||||
describe('Régression : simulation inventaire identique au monolithe', () => {
|
||||
it('8 Rousse (4♂/4♀) + 8 Dorée (4♂/4♀) + 8 Amande (4♂/4♀) → 20 bébés sur 4 générations', () => {
|
||||
const r = simulateStock({
|
||||
Rousse: { m: 4, f: 4 },
|
||||
Dorée: { m: 4, f: 4 },
|
||||
Amande: { m: 4, f: 4 },
|
||||
});
|
||||
|
||||
// Gen2 : 3 croisements × 4 bébés = 12
|
||||
const gen2 = r.crossings.filter(c => c.gen === 2);
|
||||
expect(gen2).toHaveLength(3);
|
||||
expect(gen2.map(c => c.baby).sort()).toEqual(
|
||||
['Amande et Dorée', 'Amande et Rousse', 'Dorée et Rousse'],
|
||||
);
|
||||
for (const c of gen2) expect(c.count).toBe(4);
|
||||
|
||||
// Gen3 : Ebène×2 + Indigo×2 = 4
|
||||
const gen3 = r.crossings.filter(c => c.gen === 3);
|
||||
expect(gen3).toHaveLength(2);
|
||||
expect(gen3.find(c => c.baby === 'Ebène')!.count).toBe(2);
|
||||
expect(gen3.find(c => c.baby === 'Indigo')!.count).toBe(2);
|
||||
|
||||
// Gen4 : Ebène et Indigo×2
|
||||
const gen4 = r.crossings.filter(c => c.gen === 4);
|
||||
expect(gen4).toHaveLength(1);
|
||||
expect(gen4[0]!.baby).toBe('Ebène et Indigo');
|
||||
expect(gen4[0]!.count).toBe(2);
|
||||
|
||||
// Gen5 : Orchidée×2 (Pourpre reçoit 0 car EI alloué à floor(1/2)=0)
|
||||
const gen5 = r.crossings.filter(c => c.gen === 5);
|
||||
expect(gen5).toHaveLength(1);
|
||||
expect(gen5[0]!.baby).toBe('Orchidée');
|
||||
expect(gen5[0]!.count).toBe(2);
|
||||
|
||||
expect(r.crossings.reduce((s, c) => s + c.count, 0)).toBe(20);
|
||||
});
|
||||
|
||||
it('la recette Ebène est Amande et Dorée + Dorée et Rousse (pas Amande et Rousse)', () => {
|
||||
expect(BREEDING_RECIPES['Ebène']).toEqual(['Amande et Dorée', 'Dorée et Rousse']);
|
||||
});
|
||||
|
||||
it('l\'algorithme glouton aurait donné 8 bébés sur 1 seule race — la version proportionnelle donne 12 sur 3 races', () => {
|
||||
const r = simulateStock({
|
||||
Rousse: { m: 4, f: 4 },
|
||||
Dorée: { m: 4, f: 4 },
|
||||
Amande: { m: 4, f: 4 },
|
||||
});
|
||||
// Vérifier qu'on ne voit PAS un seul croisement avec 8 bébés
|
||||
for (const c of r.crossings.filter(x => x.gen === 2)) {
|
||||
expect(c.count).toBeLessThanOrEqual(4);
|
||||
}
|
||||
expect(r.crossings.filter(c => c.gen === 2)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
164
tests/regression/level-target-timer.test.ts
Normal file
164
tests/regression/level-target-timer.test.ts
Normal file
@ -0,0 +1,164 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { computeGaugeLive, enclosGlobalState, calcLevelEtaLive } from '@presentation/helpers/gauge-live';
|
||||
import { xpForLevel } from '@domain/value-objects/XpTable';
|
||||
import { timeToGain } from '@domain/services/GaugeCalculator';
|
||||
import { createDragodinde } from '@domain/entities/Dragodinde';
|
||||
import type { Enclos } from '@domain/entities/Enclos';
|
||||
|
||||
function makeEnclos(overrides: Partial<Enclos> = {}): Enclos {
|
||||
return {
|
||||
id: 1,
|
||||
name: 'Test',
|
||||
activeGauges: ['mangeoire'],
|
||||
gaugeLevels: { baffeur: 0, caresseur: 0, foudroyeur: 0, abreuvoir: 0, dragofesse: 0, mangeoire: 50000 },
|
||||
dragodindes: [createDragodinde(1)],
|
||||
nextDdId: 2,
|
||||
timer: { running: false, startTime: null, pausedAt: null, pausedMs: 0, snapGauges: {}, snapStats: {}, gaugeRecharges: {} },
|
||||
alerted: {},
|
||||
...overrides,
|
||||
} as Enclos;
|
||||
}
|
||||
|
||||
describe('Regression: timer prend en compte levelTarget', () => {
|
||||
|
||||
it('computeGaugeLive utilise levelTarget pour le countdown mangeoire', () => {
|
||||
const enc = makeEnclos();
|
||||
const dd = { ...enc.dragodindes[0]!, levelTarget: 10 };
|
||||
|
||||
const r = computeGaugeLive(enc, dd, 'mangeoire', 0, false);
|
||||
|
||||
// Avec levelTarget=10, xpNeeded = xpForLevel(10) - xpForLevel(1) = 809
|
||||
const xpNeeded = xpForLevel(10) - xpForLevel(1);
|
||||
expect(xpNeeded).toBe(809);
|
||||
|
||||
// Le countdown devrait être timeToGain(50000, 809)
|
||||
const expectedSec = timeToGain(50000, 809);
|
||||
expect(r.cntDown).toBe(expectedSec);
|
||||
expect(r.done).toBe(false);
|
||||
});
|
||||
|
||||
it('computeGaugeLive sans levelTarget utilise 200 par défaut', () => {
|
||||
const enc = makeEnclos();
|
||||
const dd = { ...enc.dragodindes[0]!, levelTarget: null };
|
||||
|
||||
const r = computeGaugeLive(enc, dd, 'mangeoire', 0, false);
|
||||
|
||||
// Avec levelTarget=null → 200, xpNeeded >> gauge level
|
||||
// cntDown = timeToGain(50000, 50000) = drain time
|
||||
const drainTime = timeToGain(50000, 50000);
|
||||
expect(r.cntDown).toBe(drainTime);
|
||||
});
|
||||
|
||||
it('levelTarget=10 donne un countdown beaucoup plus court que levelTarget=null', () => {
|
||||
const enc = makeEnclos();
|
||||
const ddWithTarget = { ...enc.dragodindes[0]!, levelTarget: 10 };
|
||||
const ddWithoutTarget = { ...enc.dragodindes[0]!, levelTarget: null };
|
||||
|
||||
const rWith = computeGaugeLive(enc, ddWithTarget, 'mangeoire', 0, false);
|
||||
const rWithout = computeGaugeLive(enc, ddWithoutTarget, 'mangeoire', 0, false);
|
||||
|
||||
// Avec target 10 : ~410 sec. Sans target (200) : ~45000 sec.
|
||||
expect(rWith.cntDown).toBeLessThan(1000); // < ~16 minutes
|
||||
expect(rWithout.cntDown).toBeGreaterThan(40000); // > ~11 heures
|
||||
});
|
||||
|
||||
it('enclosGlobalState reflète le levelTarget dans globalMax', () => {
|
||||
const enc = makeEnclos();
|
||||
enc.dragodindes[0]!.levelTarget = 10;
|
||||
|
||||
const state = enclosGlobalState(enc);
|
||||
// Avant démarrage, started=false → globalMax devrait refléter le target 10
|
||||
expect(state.globalMax).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it('enclosGlobalState sans levelTarget a un globalMax plus élevé', () => {
|
||||
const enc = makeEnclos();
|
||||
enc.dragodindes[0]!.levelTarget = null;
|
||||
|
||||
const state = enclosGlobalState(enc);
|
||||
expect(state.globalMax).toBeGreaterThan(40000);
|
||||
});
|
||||
|
||||
it('calcLevelEtaLive utilise levelTarget', () => {
|
||||
const enc = makeEnclos();
|
||||
const dd = { ...enc.dragodindes[0]!, levelTarget: 5 };
|
||||
|
||||
const eta = calcLevelEtaLive(enc, dd, 0, false);
|
||||
// Level 1→5 needs 161 XP, timeToGain(50000, 161) = 90 sec
|
||||
expect(eta).toContain('1m');
|
||||
});
|
||||
|
||||
it('done=true quand estLevel >= levelTarget', () => {
|
||||
const enc = makeEnclos();
|
||||
// DD déjà au level 50, target 50
|
||||
const dd = { ...enc.dragodindes[0]!, stats: { ...enc.dragodindes[0]!.stats, xp: 50 }, levelTarget: 50 };
|
||||
|
||||
const r = computeGaugeLive(enc, dd, 'mangeoire', 0, false);
|
||||
expect(r.done).toBe(true);
|
||||
expect(r.cntDown).toBe(0);
|
||||
});
|
||||
|
||||
it('done=true quand estLevel > levelTarget', () => {
|
||||
const enc = makeEnclos();
|
||||
const dd = { ...enc.dragodindes[0]!, stats: { ...enc.dragodindes[0]!.stats, xp: 100 }, levelTarget: 50 };
|
||||
|
||||
const r = computeGaugeLive(enc, dd, 'mangeoire', 0, false);
|
||||
expect(r.done).toBe(true);
|
||||
});
|
||||
|
||||
it('timer en cours avec levelTarget=10 : countdown reflète le target', () => {
|
||||
const now = Date.now();
|
||||
const enc = makeEnclos({
|
||||
timer: {
|
||||
running: true,
|
||||
startTime: now - 100_000, // 100 sec écoulées
|
||||
pausedAt: null,
|
||||
pausedMs: 0,
|
||||
snapGauges: { mangeoire: 50000 },
|
||||
snapStats: { '1': { serenite: 0, endurance: 0, maturite: 0, amour: 0, xp: 1 } },
|
||||
gaugeRecharges: {},
|
||||
},
|
||||
});
|
||||
|
||||
const ddWithTarget = { ...enc.dragodindes[0]!, levelTarget: 10 };
|
||||
const ddNoTarget = { ...enc.dragodindes[0]!, levelTarget: null };
|
||||
|
||||
const rWith = computeGaugeLive(enc, ddWithTarget, 'mangeoire', 100, true);
|
||||
const rNo = computeGaugeLive(enc, ddNoTarget, 'mangeoire', 100, true);
|
||||
|
||||
// Avec target 10 : cntDown devrait être < 1000 (beaucoup moins que sans target)
|
||||
// Sans target (200) : cntDown devrait être > 30000
|
||||
expect(rWith.cntDown).toBeLessThan(rNo.cntDown);
|
||||
// Plus spécifiquement, avec target 10 après 100sec, le target devrait presque être atteint
|
||||
// xpNeeded = 809, et en 100sec (10 ticks) on gagne 10*20 = 200xp (tier 2)
|
||||
// xpRestante = 809 - 200 = 609
|
||||
expect(rWith.cntDown).toBeLessThan(500); // < 500 sec restantes
|
||||
});
|
||||
|
||||
it('changer levelTarget pendant le timer met à jour le globalMax', () => {
|
||||
const now = Date.now();
|
||||
const enc = makeEnclos({
|
||||
timer: {
|
||||
running: true,
|
||||
startTime: now - 50_000, // 50 sec écoulées
|
||||
pausedAt: null,
|
||||
pausedMs: 0,
|
||||
snapGauges: { mangeoire: 50000 },
|
||||
snapStats: { '1': { serenite: 0, endurance: 0, maturite: 0, amour: 0, xp: 1 } },
|
||||
gaugeRecharges: {},
|
||||
},
|
||||
});
|
||||
|
||||
// D'abord sans target (level 200 par défaut)
|
||||
enc.dragodindes[0]!.levelTarget = null;
|
||||
const state1 = enclosGlobalState(enc);
|
||||
|
||||
// Puis avec target 10
|
||||
enc.dragodindes[0]!.levelTarget = 10;
|
||||
const state2 = enclosGlobalState(enc);
|
||||
|
||||
// Le globalMax devrait diminuer drastiquement
|
||||
expect(state2.globalMax).toBeLessThan(state1.globalMax);
|
||||
expect(state2.globalMax).toBeLessThan(1000);
|
||||
});
|
||||
});
|
||||
40
tests/regression/stats-persistence.test.ts
Normal file
40
tests/regression/stats-persistence.test.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
|
||||
import { EventBus } from '@domain/events/EventBus';
|
||||
import { createEnclos, addDragodinde } from '@domain/entities/Enclos';
|
||||
import { createDeleteEnclosHandler } from '@application/commands/DeleteEnclos';
|
||||
import { createResetStatsHandler } from '@application/commands/ResetStats';
|
||||
|
||||
function makeState(): AppState {
|
||||
let enc = createEnclos(1);
|
||||
enc = addDragodinde(enc);
|
||||
return {
|
||||
enclos: [enc], activeId: 1, nextEnclosId: 2,
|
||||
alarmSound: 'arpege', notifsEnabled: true, ntfyTopic: '',
|
||||
archivedStats: [{ baby: 'Dorée et Rousse', couples: 3, babiesObtained: 2 }],
|
||||
inventaire: {}, workflows: [],
|
||||
accouplements: [{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 5, babiesObtained: 3, date: '' }],
|
||||
};
|
||||
}
|
||||
|
||||
describe('Regression: Stats persistence', () => {
|
||||
it('deleting enclos preserves global stats (archivedStats + accouplements)', () => {
|
||||
const state = makeState();
|
||||
const repo: StateRepository = { load: vi.fn(async () => null), save: vi.fn() };
|
||||
const events = new EventBus();
|
||||
const handler = createDeleteEnclosHandler(state, repo, events);
|
||||
handler({ type: 'delete-enclos', enclosId: 1 });
|
||||
// Enclos deleted but stats remain
|
||||
expect(state.enclos).toHaveLength(0);
|
||||
expect(state.archivedStats).toHaveLength(1);
|
||||
expect(state.accouplements).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reset stats clears everything', () => {
|
||||
const state = makeState();
|
||||
const repo: StateRepository = { load: vi.fn(async () => null), save: vi.fn() };
|
||||
createResetStatsHandler(state, repo)({ type: 'reset-stats' });
|
||||
expect(state.archivedStats).toHaveLength(0);
|
||||
expect(state.accouplements).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
52
tests/regression/xp-timer-display.test.ts
Normal file
52
tests/regression/xp-timer-display.test.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { XpCalculator } from '@domain/services/XpCalculator';
|
||||
import { xpForLevel } from '@domain/value-objects/XpTable';
|
||||
import { timeToGain } from '@domain/services/GaugeCalculator';
|
||||
|
||||
describe('Regression: XP timer display (v1.1.4 bug)', () => {
|
||||
const calc = new XpCalculator();
|
||||
|
||||
it('should NOT show ~23h for level 1→5 with tier 2 mangeoire', () => {
|
||||
const r = calc.computeEta({
|
||||
currentLevel: 1, target: 5,
|
||||
gaugeLevels: { mangeoire: 50000 },
|
||||
activeGauges: ['mangeoire'],
|
||||
});
|
||||
// XP needed: xpForLevel(5) - xpForLevel(1) = 161 - 0 = 161
|
||||
// timeToGain(50000, 161): tier2 zone (10000 pts at rate 20) → ceil(161/20)*10 = 90 sec
|
||||
expect(r.seconds).toBe(90);
|
||||
expect(r.seconds).toBeLessThan(3600); // Must be under 1h, NOT 23h
|
||||
});
|
||||
|
||||
it('should NOT show ~23h for level 1→10 with tier 1 mangeoire', () => {
|
||||
const r = calc.computeEta({
|
||||
currentLevel: 1, target: 10,
|
||||
gaugeLevels: { mangeoire: 30000 },
|
||||
activeGauges: ['mangeoire'],
|
||||
});
|
||||
// XP needed: xpForLevel(10) - xpForLevel(1) = 809
|
||||
// timeToGain(30000, 809): tier1 zone → ceil(809/10)*10 = 810 sec
|
||||
expect(r.seconds).toBe(810);
|
||||
expect(r.seconds).toBeLessThan(3600);
|
||||
});
|
||||
|
||||
it('XP model uses depleting gauge (same as other stats)', () => {
|
||||
// Bug fix: XP uses gainedIn/timeToGain (depleting gauge), NOT a fixed rate.
|
||||
// The gauge depletes from 70000 → 0 giving decreasing XP per tick.
|
||||
// Real scenario: gauge 70000, level 1→67, xpNeeded = 67942
|
||||
const xpNeeded = xpForLevel(67) - xpForLevel(1); // 67942
|
||||
const sec = timeToGain(70000, xpNeeded);
|
||||
// Tier2 (70000→40000): 30000pts at rate20 = 15000sec
|
||||
// Tier1 (40000→2058): 37942pts at rate10 = 37950sec
|
||||
// Total: 52950sec ≈ 14h42m
|
||||
expect(sec).toBe(52950);
|
||||
expect(sec).toBeLessThan(200000);
|
||||
});
|
||||
|
||||
it('returns Infinity when gauge cannot provide enough XP without recharge', () => {
|
||||
// Gauge 50000 = 50000 total pts. Level 1→100 needs 172668 XP → impossible without recharge.
|
||||
const xpNeeded = xpForLevel(100) - xpForLevel(1); // 172668
|
||||
const sec = timeToGain(50000, xpNeeded);
|
||||
expect(sec).toBe(Infinity);
|
||||
});
|
||||
});
|
||||
45
tests/unit/application/CommandBus.test.ts
Normal file
45
tests/unit/application/CommandBus.test.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { CommandBus } from '@application/handlers/CommandBus';
|
||||
import { QueryBus } from '@application/handlers/QueryBus';
|
||||
|
||||
describe('CommandBus', () => {
|
||||
it('dispatches to registered handler', () => {
|
||||
const bus = new CommandBus();
|
||||
const handler = vi.fn();
|
||||
bus.register('start-timer', handler);
|
||||
bus.execute({ type: 'start-timer', enclosId: 1 });
|
||||
expect(handler).toHaveBeenCalledWith({ type: 'start-timer', enclosId: 1 });
|
||||
});
|
||||
|
||||
it('throws on unknown command', () => {
|
||||
const bus = new CommandBus();
|
||||
expect(() => bus.execute({ type: 'unknown' })).toThrow('No handler for command: unknown');
|
||||
});
|
||||
|
||||
it('has returns true for registered', () => {
|
||||
const bus = new CommandBus();
|
||||
bus.register('test', vi.fn());
|
||||
expect(bus.has('test')).toBe(true);
|
||||
expect(bus.has('other')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('QueryBus', () => {
|
||||
it('returns value from handler', () => {
|
||||
const bus = new QueryBus();
|
||||
bus.register('get-dashboard', () => ({ total: 42 }));
|
||||
const result = bus.execute<{ total: number }>({ type: 'get-dashboard' });
|
||||
expect(result.total).toBe(42);
|
||||
});
|
||||
|
||||
it('throws on unknown query', () => {
|
||||
const bus = new QueryBus();
|
||||
expect(() => bus.execute({ type: 'unknown' })).toThrow('No handler for query: unknown');
|
||||
});
|
||||
|
||||
it('has returns true for registered', () => {
|
||||
const bus = new QueryBus();
|
||||
bus.register('test', () => null);
|
||||
expect(bus.has('test')).toBe(true);
|
||||
});
|
||||
});
|
||||
822
tests/unit/application/commands.test.ts
Normal file
822
tests/unit/application/commands.test.ts
Normal file
@ -0,0 +1,822 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
|
||||
import { EventBus } from '@domain/events/EventBus';
|
||||
import { createEnclos, addDragodinde } from '@domain/entities/Enclos';
|
||||
import { createStartTimerHandler } from '@application/commands/StartTimer';
|
||||
import { createStopTimerHandler } from '@application/commands/StopTimer';
|
||||
import { createCreateEnclosHandler } from '@application/commands/CreateEnclos';
|
||||
import { createDeleteEnclosHandler } from '@application/commands/DeleteEnclos';
|
||||
import { createAddDragodindeHandler } from '@application/commands/AddDragodinde';
|
||||
import { createRemoveDragodindeHandler } from '@application/commands/RemoveDragodinde';
|
||||
import { createToggleGaugeHandler, createUpdateGaugeLevelHandler } from '@application/commands/UpdateGauge';
|
||||
import { createRegisterAccouplementHandler } from '@application/commands/RegisterAccouplement';
|
||||
import { createUpdateSettingsHandler } from '@application/commands/UpdateSettings';
|
||||
import { createResetStatsHandler } from '@application/commands/ResetStats';
|
||||
import { createRechargeGaugeHandler } from '@application/commands/RechargeGauge';
|
||||
import { createResetTimerHandler, createClearEnclosHandler, createRenameEnclosHandler, createNouvelleFourneeHandler } from '@application/commands/EnclosActions';
|
||||
import { createSaveWorkflowHandler } from '@application/commands/SaveWorkflow';
|
||||
import { createCompleteTimerHandler } from '@application/commands/CompleteTimer';
|
||||
import { createDeleteWorkflowHandler } from '@application/commands/DeleteWorkflow';
|
||||
import { createRenameDragodindeHandler, createUpdateDdStatHandler, createUpdateDdSerenTargetHandler, createUpdateDdLevelTargetHandler, createReorderDragodindeHandler } from '@application/commands/DragodindeActions';
|
||||
import { createImportWorkflowsHandler } from '@application/commands/ImportWorkflows';
|
||||
import { createReorderEnclosHandler } from '@application/commands/ReorderEnclos';
|
||||
import { createUpdateWorkflowHandler } from '@application/commands/UpdateWorkflow';
|
||||
import type { WorkflowItem } from '@application/queries/GetWorkflows';
|
||||
|
||||
function makeState(): AppState {
|
||||
let enc = createEnclos(1);
|
||||
enc = addDragodinde(enc);
|
||||
enc.activeGauges = ['baffeur'];
|
||||
enc.gaugeLevels.baffeur = 50000;
|
||||
return {
|
||||
enclos: [enc], activeId: 1, nextEnclosId: 2,
|
||||
alarmSound: 'arpege', notifsEnabled: true, ntfyTopic: '',
|
||||
archivedStats: [], inventaire: {}, workflows: [], accouplements: [],
|
||||
};
|
||||
}
|
||||
|
||||
function makeRepo(): StateRepository {
|
||||
return { load: vi.fn(async () => null), save: vi.fn() };
|
||||
}
|
||||
|
||||
describe('StartTimer', () => {
|
||||
it('starts timer on enclos', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
const handler = createStartTimerHandler(state, repo);
|
||||
handler({ type: 'start-timer', enclosId: 1 });
|
||||
expect(state.enclos[0]!.timer.running).toBe(true);
|
||||
expect(state.enclos[0]!.timer.startTime).not.toBeNull();
|
||||
expect(state.enclos[0]!.timer.snapGauges).toHaveProperty('baffeur', 50000);
|
||||
expect(repo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores if already running', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
const handler = createStartTimerHandler(state, repo);
|
||||
handler({ type: 'start-timer', enclosId: 1 });
|
||||
handler({ type: 'start-timer', enclosId: 1 }); // second call
|
||||
expect(repo.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ignores if no gauges active', () => {
|
||||
const state = makeState();
|
||||
state.enclos[0]!.activeGauges = [];
|
||||
const repo = makeRepo();
|
||||
const handler = createStartTimerHandler(state, repo);
|
||||
handler({ type: 'start-timer', enclosId: 1 });
|
||||
expect(state.enclos[0]!.timer.running).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StopTimer', () => {
|
||||
it('stops running timer', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createStartTimerHandler(state, repo)({ type: 'start-timer', enclosId: 1 });
|
||||
createStopTimerHandler(state, repo)({ type: 'stop-timer', enclosId: 1 });
|
||||
expect(state.enclos[0]!.timer.running).toBe(false);
|
||||
expect(state.enclos[0]!.timer.pausedAt).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CreateEnclos', () => {
|
||||
it('creates and activates new enclos', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createCreateEnclosHandler(state, repo)({ type: 'create-enclos' });
|
||||
expect(state.enclos).toHaveLength(2);
|
||||
expect(state.activeId).toBe(2);
|
||||
expect(state.enclos[1]!.dragodindes).toHaveLength(1); // starts with 1 DD
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeleteEnclos', () => {
|
||||
it('deletes and switches active', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
const events = new EventBus();
|
||||
const handler = vi.fn();
|
||||
events.on('enclos-deleted', handler);
|
||||
createDeleteEnclosHandler(state, repo, events)({ type: 'delete-enclos', enclosId: 1 });
|
||||
expect(state.enclos).toHaveLength(0);
|
||||
expect(state.activeId).toBe('dashboard');
|
||||
expect(handler).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AddDragodinde', () => {
|
||||
it('adds DD to enclos', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createAddDragodindeHandler(state, repo)({ type: 'add-dragodinde', enclosId: 1 });
|
||||
expect(state.enclos[0]!.dragodindes).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RemoveDragodinde', () => {
|
||||
it('removes DD from enclos', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createRemoveDragodindeHandler(state, repo)({ type: 'remove-dragodinde', enclosId: 1, ddId: 1 });
|
||||
expect(state.enclos[0]!.dragodindes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToggleGauge', () => {
|
||||
it('toggles gauge on', () => {
|
||||
const state = makeState();
|
||||
state.enclos[0]!.activeGauges = [];
|
||||
const repo = makeRepo();
|
||||
createToggleGaugeHandler(state, repo)({ type: 'toggle-gauge', enclosId: 1, gaugeId: 'foudroyeur' });
|
||||
expect(state.enclos[0]!.activeGauges).toContain('foudroyeur');
|
||||
});
|
||||
|
||||
it('toggles gauge off', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createToggleGaugeHandler(state, repo)({ type: 'toggle-gauge', enclosId: 1, gaugeId: 'baffeur' });
|
||||
expect(state.enclos[0]!.activeGauges).not.toContain('baffeur');
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdateGaugeLevel', () => {
|
||||
it('clamps level between 0 and 100000', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createUpdateGaugeLevelHandler(state, repo)({ type: 'update-gauge-level', enclosId: 1, gaugeId: 'baffeur', level: 150000 });
|
||||
expect(state.enclos[0]!.gaugeLevels.baffeur).toBe(100000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RegisterAccouplement', () => {
|
||||
it('adds accouplement to state', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
const events = new EventBus();
|
||||
createRegisterAccouplementHandler(state, repo, events)({
|
||||
type: 'register-accouplement',
|
||||
parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', couples: 5, babiesObtained: 3,
|
||||
});
|
||||
expect(state.accouplements).toHaveLength(1);
|
||||
expect(state.accouplements[0]!.baby).toBe('Dorée et Rousse');
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdateSettings', () => {
|
||||
it('updates alarm sound', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createUpdateSettingsHandler(state, repo)({ type: 'update-settings', alarmSound: 'cloche' });
|
||||
expect(state.alarmSound).toBe('cloche');
|
||||
});
|
||||
|
||||
it('persists inventaire', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
const inv = { Rousse: { m: 3, f: 2 }, Dorée: { m: 1, f: 4 } };
|
||||
createUpdateSettingsHandler(state, repo)({ type: 'update-settings', inventaire: inv });
|
||||
expect(state.inventaire).toEqual(inv);
|
||||
expect(repo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ne touche pas aux autres champs si seul inventaire est fourni', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createUpdateSettingsHandler(state, repo)({ type: 'update-settings', inventaire: { Rousse: { m: 1, f: 0 } } });
|
||||
expect(state.alarmSound).toBe('arpege');
|
||||
expect(state.notifsEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SaveWorkflow', () => {
|
||||
it('crée un workflow avec matériaux et étapes', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
const handler = createSaveWorkflowHandler(state, repo);
|
||||
handler({
|
||||
type: 'save-workflow',
|
||||
target: 'Dorée et Rousse',
|
||||
qty: 4,
|
||||
materials: [{ race: 'Rousse', m: 4, f: 4 }, { race: 'Dorée', m: 4, f: 4 }],
|
||||
steps: [{ baby: 'Dorée et Rousse', parentA: 'Rousse', parentB: 'Dorée', couples: 4, gen: 2 }],
|
||||
repro: {},
|
||||
});
|
||||
expect(state.workflows).toHaveLength(1);
|
||||
expect(state.workflows[0]!.target).toBe('Dorée et Rousse');
|
||||
expect(state.workflows[0]!.qty).toBe(4);
|
||||
expect(state.workflows[0]!.materials).toHaveLength(2);
|
||||
expect(state.workflows[0]!.materials[0]!.needed).toBe(8); // m + f
|
||||
expect(state.workflows[0]!.steps).toHaveLength(1);
|
||||
expect(state.workflows[0]!.steps[0]!.gen).toBe(2);
|
||||
expect(state.workflows[0]!.steps[0]!.crossings).toHaveLength(1);
|
||||
expect(repo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('groupe les étapes par génération', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createSaveWorkflowHandler(state, repo)({
|
||||
type: 'save-workflow',
|
||||
target: 'Ebène',
|
||||
qty: 2,
|
||||
materials: [],
|
||||
steps: [
|
||||
{ baby: 'Dorée et Rousse', parentA: 'Rousse', parentB: 'Dorée', couples: 4, gen: 2 },
|
||||
{ baby: 'Amande et Dorée', parentA: 'Amande', parentB: 'Dorée', couples: 4, gen: 2 },
|
||||
{ baby: 'Ebène', parentA: 'Amande et Dorée', parentB: 'Dorée et Rousse', couples: 2, gen: 3 },
|
||||
],
|
||||
repro: {},
|
||||
});
|
||||
const wf = state.workflows[0]!;
|
||||
expect(wf.steps).toHaveLength(2); // gen 2 + gen 3
|
||||
expect(wf.steps[0]!.gen).toBe(2);
|
||||
expect(wf.steps[0]!.crossings).toHaveLength(2);
|
||||
expect(wf.steps[1]!.gen).toBe(3);
|
||||
expect(wf.steps[1]!.crossings).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ResetStats', () => {
|
||||
it('clears stats and accouplements', () => {
|
||||
const state = makeState();
|
||||
state.archivedStats = [{ a: 1 }];
|
||||
state.accouplements = [{ parentA: 'A', parentB: 'B', baby: 'C', gen: 2, couples: 1, babiesObtained: 1, date: '' }];
|
||||
const repo = makeRepo();
|
||||
createResetStatsHandler(state, repo)({ type: 'reset-stats' });
|
||||
expect(state.archivedStats).toHaveLength(0);
|
||||
expect(state.accouplements).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RechargeGauge', () => {
|
||||
function makeRunningState(): AppState {
|
||||
const state = makeState();
|
||||
// Simuler un timer démarré
|
||||
state.enclos[0]!.timer = {
|
||||
running: true,
|
||||
startTime: Date.now() - 3600_000, // démarré y'a 1h
|
||||
pausedAt: null,
|
||||
pausedMs: 0,
|
||||
snapGauges: { baffeur: 50000 },
|
||||
snapStats: {},
|
||||
gaugeRecharges: {},
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
it('enregistre une recharge avec le elapsed courant', () => {
|
||||
const state = makeRunningState();
|
||||
const repo = makeRepo();
|
||||
const handler = createRechargeGaugeHandler(state, repo);
|
||||
handler({ type: 'recharge-gauge', enclosId: 1, gaugeId: 'baffeur', level: 90000 });
|
||||
|
||||
const recharges = state.enclos[0]!.timer.gaugeRecharges['baffeur'];
|
||||
expect(recharges).toHaveLength(1);
|
||||
expect(recharges![0]!.level).toBe(90000);
|
||||
// atSec ≈ 3600 sec (1h de timer)
|
||||
expect(recharges![0]!.atSec).toBeGreaterThan(3500);
|
||||
expect(recharges![0]!.atSec).toBeLessThan(3700);
|
||||
});
|
||||
|
||||
it('met à jour gaugeLevels pour l\'affichage', () => {
|
||||
const state = makeRunningState();
|
||||
const repo = makeRepo();
|
||||
createRechargeGaugeHandler(state, repo)({ type: 'recharge-gauge', enclosId: 1, gaugeId: 'baffeur', level: 75000 });
|
||||
expect(state.enclos[0]!.timer.gaugeRecharges['baffeur']![0]!.level).toBe(75000);
|
||||
expect(repo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('recharges proches (<2s) sont consolidées (saisie temps réel)', () => {
|
||||
const state = makeRunningState();
|
||||
const repo = makeRepo();
|
||||
const handler = createRechargeGaugeHandler(state, repo);
|
||||
handler({ type: 'recharge-gauge', enclosId: 1, gaugeId: 'baffeur', level: 70000 });
|
||||
handler({ type: 'recharge-gauge', enclosId: 1, gaugeId: 'baffeur', level: 90000 });
|
||||
// Les deux appels sont quasi-simultanés → consolidés en une seule entrée
|
||||
const arr = state.enclos[0]!.timer.gaugeRecharges['baffeur']!;
|
||||
expect(arr).toHaveLength(1);
|
||||
expect(arr[0]!.level).toBe(90000);
|
||||
});
|
||||
|
||||
it('ignore si le timer n\'est pas en cours', () => {
|
||||
const state = makeState();
|
||||
// timer non démarré
|
||||
const repo = makeRepo();
|
||||
createRechargeGaugeHandler(state, repo)({ type: 'recharge-gauge', enclosId: 1, gaugeId: 'baffeur', level: 90000 });
|
||||
expect(state.enclos[0]!.timer.gaugeRecharges['baffeur']).toBeUndefined();
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reset-timer efface les recharges', () => {
|
||||
const state = makeRunningState();
|
||||
const repo = makeRepo();
|
||||
createRechargeGaugeHandler(state, repo)({ type: 'recharge-gauge', enclosId: 1, gaugeId: 'baffeur', level: 90000 });
|
||||
expect(state.enclos[0]!.timer.gaugeRecharges['baffeur']).toHaveLength(1);
|
||||
createResetTimerHandler(state, repo)({ type: 'reset-timer', enclosId: 1 });
|
||||
expect(state.enclos[0]!.timer.gaugeRecharges).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('CompleteTimer', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function makeRunningTimerState(): AppState {
|
||||
const state = makeState();
|
||||
const enc = state.enclos[0]!;
|
||||
enc.activeGauges = ['foudroyeur'];
|
||||
enc.gaugeLevels.foudroyeur = 80000;
|
||||
enc.dragodindes[0]!.stats.endurance = 100;
|
||||
enc.timer = {
|
||||
running: true,
|
||||
startTime: Date.now() - 7200_000, // démarré y'a 2h
|
||||
pausedAt: null,
|
||||
pausedMs: 0,
|
||||
snapGauges: { foudroyeur: 80000 },
|
||||
snapStats: { 1: { endurance: 100 } },
|
||||
gaugeRecharges: {},
|
||||
};
|
||||
enc.alerted = {};
|
||||
return state;
|
||||
}
|
||||
|
||||
it('arrête le timer et persiste les stats finales', () => {
|
||||
vi.useFakeTimers();
|
||||
const now = Date.now();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
const state = makeState();
|
||||
const enc = state.enclos[0]!;
|
||||
enc.activeGauges = ['foudroyeur'];
|
||||
enc.gaugeLevels.foudroyeur = 80000;
|
||||
enc.dragodindes[0]!.stats.endurance = 100;
|
||||
enc.timer = {
|
||||
running: true,
|
||||
startTime: now - 7200_000,
|
||||
pausedAt: null,
|
||||
pausedMs: 0,
|
||||
snapGauges: { foudroyeur: 80000 },
|
||||
snapStats: { 1: { endurance: 100 } },
|
||||
gaugeRecharges: {},
|
||||
};
|
||||
enc.alerted = {};
|
||||
|
||||
const repo = makeRepo();
|
||||
const events = new EventBus();
|
||||
const handler = createCompleteTimerHandler(state, repo, events);
|
||||
handler({ type: 'complete-timer', enclosId: 1 });
|
||||
|
||||
expect(enc.timer.running).toBe(false);
|
||||
expect(enc.timer.pausedAt).toBe(now);
|
||||
expect(enc.alerted['__done__']).toBe(true);
|
||||
// endurance doit avoir augmenté par rapport à la valeur initiale
|
||||
expect(enc.dragodindes[0]!.stats.endurance).toBeGreaterThan(100);
|
||||
expect(repo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('émet l\'événement timer-completed', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(Date.now());
|
||||
|
||||
const state = makeRunningTimerState();
|
||||
const repo = makeRepo();
|
||||
const events = new EventBus();
|
||||
const eventHandler = vi.fn();
|
||||
events.on('timer-completed', eventHandler);
|
||||
|
||||
createCompleteTimerHandler(state, repo, events)({ type: 'complete-timer', enclosId: 1 });
|
||||
expect(eventHandler).toHaveBeenCalledWith(expect.objectContaining({ type: 'timer-completed' }));
|
||||
});
|
||||
|
||||
it('ignore si l\'enclos n\'existe pas', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
const events = new EventBus();
|
||||
createCompleteTimerHandler(state, repo, events)({ type: 'complete-timer', enclosId: 999 });
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignore si le timer n\'est pas en cours', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
const events = new EventBus();
|
||||
createCompleteTimerHandler(state, repo, events)({ type: 'complete-timer', enclosId: 1 });
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignore si déjà complété (__done__)', () => {
|
||||
const state = makeRunningTimerState();
|
||||
state.enclos[0]!.alerted['__done__'] = true;
|
||||
const repo = makeRepo();
|
||||
const events = new EventBus();
|
||||
createCompleteTimerHandler(state, repo, events)({ type: 'complete-timer', enclosId: 1 });
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeleteWorkflow', () => {
|
||||
it('supprime un workflow par id', () => {
|
||||
const state = makeState();
|
||||
state.workflows = [{ id: 42, name: 'wf1', target: 'Dorée', qty: 1, createdAt: 0, materials: [], steps: [] }] as WorkflowItem[];
|
||||
const repo = makeRepo();
|
||||
createDeleteWorkflowHandler(state, repo)({ type: 'delete-workflow', workflowId: 42 });
|
||||
expect(state.workflows).toHaveLength(0);
|
||||
expect(repo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ne fait rien si le workflow n\'existe pas', () => {
|
||||
const state = makeState();
|
||||
state.workflows = [{ id: 42, name: 'wf1', target: 'Dorée', qty: 1, createdAt: 0, materials: [], steps: [] }] as WorkflowItem[];
|
||||
const repo = makeRepo();
|
||||
createDeleteWorkflowHandler(state, repo)({ type: 'delete-workflow', workflowId: 999 });
|
||||
expect(state.workflows).toHaveLength(1);
|
||||
expect(repo.save).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RenameDragodinde', () => {
|
||||
it('renomme une dragodinde', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createRenameDragodindeHandler(state, repo)({ type: 'rename-dragodinde', enclosId: 1, ddId: 1, name: 'Flamme' });
|
||||
expect(state.enclos[0]!.dragodindes[0]!.name).toBe('Flamme');
|
||||
expect(repo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('trim le nom', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createRenameDragodindeHandler(state, repo)({ type: 'rename-dragodinde', enclosId: 1, ddId: 1, name: ' Flamme ' });
|
||||
expect(state.enclos[0]!.dragodindes[0]!.name).toBe('Flamme');
|
||||
});
|
||||
|
||||
it('ignore si le nom est vide ou whitespace', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createRenameDragodindeHandler(state, repo)({ type: 'rename-dragodinde', enclosId: 1, ddId: 1, name: ' ' });
|
||||
expect(state.enclos[0]!.dragodindes[0]!.name).toBe('Dragodinde 1');
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignore si l\'enclos n\'existe pas', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createRenameDragodindeHandler(state, repo)({ type: 'rename-dragodinde', enclosId: 999, ddId: 1, name: 'X' });
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignore si la DD n\'existe pas', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createRenameDragodindeHandler(state, repo)({ type: 'rename-dragodinde', enclosId: 1, ddId: 999, name: 'X' });
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdateDdStat', () => {
|
||||
it('met à jour une stat de la DD', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createUpdateDdStatHandler(state, repo)({ type: 'update-dd-stat', enclosId: 1, ddId: 1, stat: 'endurance', value: 5000 });
|
||||
expect(state.enclos[0]!.dragodindes[0]!.stats.endurance).toBe(5000);
|
||||
expect(repo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignore si l\'enclos n\'existe pas', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createUpdateDdStatHandler(state, repo)({ type: 'update-dd-stat', enclosId: 999, ddId: 1, stat: 'endurance', value: 5000 });
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignore si la DD n\'existe pas', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createUpdateDdStatHandler(state, repo)({ type: 'update-dd-stat', enclosId: 1, ddId: 999, stat: 'endurance', value: 5000 });
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdateDdSerenTarget', () => {
|
||||
it('définit une cible de sérénité', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createUpdateDdSerenTargetHandler(state, repo)({ type: 'update-dd-seren-target', enclosId: 1, ddId: 1, target: -2000 });
|
||||
expect(state.enclos[0]!.dragodindes[0]!.sereniteTarget).toBe(-2000);
|
||||
expect(repo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepte null pour retirer la cible', () => {
|
||||
const state = makeState();
|
||||
state.enclos[0]!.dragodindes[0]!.sereniteTarget = -2000;
|
||||
const repo = makeRepo();
|
||||
createUpdateDdSerenTargetHandler(state, repo)({ type: 'update-dd-seren-target', enclosId: 1, ddId: 1, target: null });
|
||||
expect(state.enclos[0]!.dragodindes[0]!.sereniteTarget).toBeNull();
|
||||
});
|
||||
|
||||
it('ignore si l\'enclos n\'existe pas', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createUpdateDdSerenTargetHandler(state, repo)({ type: 'update-dd-seren-target', enclosId: 999, ddId: 1, target: -2000 });
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignore si la DD n\'existe pas', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createUpdateDdSerenTargetHandler(state, repo)({ type: 'update-dd-seren-target', enclosId: 1, ddId: 999, target: -2000 });
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdateDdLevelTarget', () => {
|
||||
it('définit une cible de niveau', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createUpdateDdLevelTargetHandler(state, repo)({ type: 'update-dd-level-target', enclosId: 1, ddId: 1, target: 100 });
|
||||
expect(state.enclos[0]!.dragodindes[0]!.levelTarget).toBe(100);
|
||||
expect(repo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepte null pour retirer la cible', () => {
|
||||
const state = makeState();
|
||||
state.enclos[0]!.dragodindes[0]!.levelTarget = 100;
|
||||
const repo = makeRepo();
|
||||
createUpdateDdLevelTargetHandler(state, repo)({ type: 'update-dd-level-target', enclosId: 1, ddId: 1, target: null });
|
||||
expect(state.enclos[0]!.dragodindes[0]!.levelTarget).toBeNull();
|
||||
});
|
||||
|
||||
it('ignore si l\'enclos n\'existe pas', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createUpdateDdLevelTargetHandler(state, repo)({ type: 'update-dd-level-target', enclosId: 999, ddId: 1, target: 100 });
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReorderDragodinde', () => {
|
||||
it('réordonne les DDs dans l\'enclos', () => {
|
||||
const state = makeState();
|
||||
// Ajouter une 2e DD
|
||||
state.enclos[0] = addDragodinde(state.enclos[0]!);
|
||||
const repo = makeRepo();
|
||||
const dd1Id = state.enclos[0]!.dragodindes[0]!.id;
|
||||
const dd2Id = state.enclos[0]!.dragodindes[1]!.id;
|
||||
createReorderDragodindeHandler(state, repo)({ type: 'reorder-dragodinde', enclosId: 1, fromDdId: dd1Id, toDdId: dd2Id });
|
||||
expect(state.enclos[0]!.dragodindes[0]!.id).toBe(dd2Id);
|
||||
expect(state.enclos[0]!.dragodindes[1]!.id).toBe(dd1Id);
|
||||
expect(repo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignore si l\'enclos n\'existe pas', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createReorderDragodindeHandler(state, repo)({ type: 'reorder-dragodinde', enclosId: 999, fromDdId: 1, toDdId: 2 });
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignore si un des ddId n\'existe pas', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createReorderDragodindeHandler(state, repo)({ type: 'reorder-dragodinde', enclosId: 1, fromDdId: 1, toDdId: 999 });
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ImportWorkflows', () => {
|
||||
it('importe des workflows dans la liste existante', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
const wf: WorkflowItem = { id: 10, name: 'Import1', target: 'Dorée', qty: 2, createdAt: 0, materials: [], steps: [] };
|
||||
createImportWorkflowsHandler(state, repo)({ type: 'import-workflows', workflows: [wf] });
|
||||
expect(state.workflows).toHaveLength(1);
|
||||
expect((state.workflows[0] as WorkflowItem).target).toBe('Dorée');
|
||||
expect(repo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('réattribue un id si doublon', () => {
|
||||
const state = makeState();
|
||||
const existingWf: WorkflowItem = { id: 10, name: 'Existant', target: 'Rousse', qty: 1, createdAt: 0, materials: [], steps: [] };
|
||||
state.workflows = [existingWf] as any;
|
||||
const repo = makeRepo();
|
||||
const importWf: WorkflowItem = { id: 10, name: 'Import1', target: 'Dorée', qty: 2, createdAt: 0, materials: [], steps: [] };
|
||||
createImportWorkflowsHandler(state, repo)({ type: 'import-workflows', workflows: [importWf] });
|
||||
expect(state.workflows).toHaveLength(2);
|
||||
// L'id du workflow importé doit avoir changé (plus 10)
|
||||
expect((state.workflows[1] as WorkflowItem).id).not.toBe(10);
|
||||
expect((state.workflows[1] as WorkflowItem).target).toBe('Dorée');
|
||||
});
|
||||
|
||||
it('importe plusieurs workflows sans conflit d\'id', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
const wf1: WorkflowItem = { id: 1, name: 'A', target: 'A', qty: 1, createdAt: 0, materials: [], steps: [] };
|
||||
const wf2: WorkflowItem = { id: 2, name: 'B', target: 'B', qty: 1, createdAt: 0, materials: [], steps: [] };
|
||||
createImportWorkflowsHandler(state, repo)({ type: 'import-workflows', workflows: [wf1, wf2] });
|
||||
expect(state.workflows).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReorderEnclos', () => {
|
||||
it('réordonne les enclos', () => {
|
||||
const state = makeState();
|
||||
// Ajouter un 2e enclos
|
||||
const enc2 = createEnclos(2, 'Enclos 2');
|
||||
state.enclos.push(enc2);
|
||||
const repo = makeRepo();
|
||||
createReorderEnclosHandler(state, repo)({ type: 'reorder-enclos', fromIndex: 0, toIndex: 1 });
|
||||
expect(state.enclos[0]!.id).toBe(2);
|
||||
expect(state.enclos[1]!.id).toBe(1);
|
||||
expect(repo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignore si fromIndex est négatif', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createReorderEnclosHandler(state, repo)({ type: 'reorder-enclos', fromIndex: -1, toIndex: 0 });
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignore si toIndex est négatif', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createReorderEnclosHandler(state, repo)({ type: 'reorder-enclos', fromIndex: 0, toIndex: -1 });
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignore si fromIndex dépasse la taille du tableau', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createReorderEnclosHandler(state, repo)({ type: 'reorder-enclos', fromIndex: 5, toIndex: 0 });
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignore si toIndex dépasse la taille du tableau', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createReorderEnclosHandler(state, repo)({ type: 'reorder-enclos', fromIndex: 0, toIndex: 5 });
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdateWorkflow', () => {
|
||||
function makeWorkflowState(): AppState {
|
||||
const state = makeState();
|
||||
const wf: WorkflowItem = {
|
||||
id: 1, name: 'Workflow 1', target: 'Dorée', qty: 2, createdAt: 0,
|
||||
materials: [{ name: 'Rousse', needed: 4, done: 0 }, { name: 'Dorée', needed: 4, done: 0 }],
|
||||
steps: [{
|
||||
gen: 2,
|
||||
crossings: [{ race: 'Dorée et Rousse', needed: 2, parentA: 'Rousse', parentB: 'Dorée', couples: 2, repro: 0, done: 0 }],
|
||||
}],
|
||||
};
|
||||
state.workflows = [wf] as any;
|
||||
return state;
|
||||
}
|
||||
|
||||
it('met à jour le done d\'un matériau', () => {
|
||||
const state = makeWorkflowState();
|
||||
const repo = makeRepo();
|
||||
createUpdateWorkflowHandler(state, repo)({ type: 'update-workflow', workflowId: 1, materialIdx: 0, done: 3 });
|
||||
expect((state.workflows[0] as WorkflowItem).materials[0]!.done).toBe(3);
|
||||
expect(repo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('met à jour le done d\'un crossing', () => {
|
||||
const state = makeWorkflowState();
|
||||
const repo = makeRepo();
|
||||
createUpdateWorkflowHandler(state, repo)({ type: 'update-workflow', workflowId: 1, stepIdx: 0, crossingIdx: 0, done: 1 });
|
||||
expect((state.workflows[0] as WorkflowItem).steps[0]!.crossings[0]!.done).toBe(1);
|
||||
expect(repo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clamp done à 0 minimum pour matériau', () => {
|
||||
const state = makeWorkflowState();
|
||||
const repo = makeRepo();
|
||||
createUpdateWorkflowHandler(state, repo)({ type: 'update-workflow', workflowId: 1, materialIdx: 0, done: -5 });
|
||||
expect((state.workflows[0] as WorkflowItem).materials[0]!.done).toBe(0);
|
||||
});
|
||||
|
||||
it('clamp done à 0 minimum pour crossing', () => {
|
||||
const state = makeWorkflowState();
|
||||
const repo = makeRepo();
|
||||
createUpdateWorkflowHandler(state, repo)({ type: 'update-workflow', workflowId: 1, stepIdx: 0, crossingIdx: 0, done: -3 });
|
||||
expect((state.workflows[0] as WorkflowItem).steps[0]!.crossings[0]!.done).toBe(0);
|
||||
});
|
||||
|
||||
it('ignore si le workflow n\'existe pas', () => {
|
||||
const state = makeWorkflowState();
|
||||
const repo = makeRepo();
|
||||
createUpdateWorkflowHandler(state, repo)({ type: 'update-workflow', workflowId: 999, materialIdx: 0, done: 3 });
|
||||
// L'état ne change pas mais save est quand même appelé (pas de guard sur save)
|
||||
expect((state.workflows[0] as WorkflowItem).materials[0]!.done).toBe(0);
|
||||
});
|
||||
|
||||
it('ignore si materialIdx hors limites', () => {
|
||||
const state = makeWorkflowState();
|
||||
const repo = makeRepo();
|
||||
createUpdateWorkflowHandler(state, repo)({ type: 'update-workflow', workflowId: 1, materialIdx: 99, done: 3 });
|
||||
expect((state.workflows[0] as WorkflowItem).materials[0]!.done).toBe(0);
|
||||
});
|
||||
|
||||
it('ignore si stepIdx ou crossingIdx hors limites', () => {
|
||||
const state = makeWorkflowState();
|
||||
const repo = makeRepo();
|
||||
createUpdateWorkflowHandler(state, repo)({ type: 'update-workflow', workflowId: 1, stepIdx: 99, crossingIdx: 0, done: 1 });
|
||||
expect((state.workflows[0] as WorkflowItem).steps[0]!.crossings[0]!.done).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ClearEnclos', () => {
|
||||
it('vide les DDs, jauges et timer de l\'enclos', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createClearEnclosHandler(state, repo)({ type: 'clear-enclos', enclosId: 1 });
|
||||
const enc = state.enclos[0]!;
|
||||
expect(enc.dragodindes).toHaveLength(0);
|
||||
expect(enc.nextDdId).toBe(1);
|
||||
expect(enc.activeGauges).toHaveLength(0);
|
||||
expect(enc.gaugeLevels.baffeur).toBe(0);
|
||||
expect(enc.timer.running).toBe(false);
|
||||
expect(enc.timer.snapGauges).toEqual({});
|
||||
expect(enc.alerted).toEqual({});
|
||||
expect(repo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignore si l\'enclos n\'existe pas', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createClearEnclosHandler(state, repo)({ type: 'clear-enclos', enclosId: 999 });
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RenameEnclos', () => {
|
||||
it('renomme un enclos', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createRenameEnclosHandler(state, repo)({ type: 'rename-enclos', enclosId: 1, name: 'Mon enclos' });
|
||||
expect(state.enclos[0]!.name).toBe('Mon enclos');
|
||||
expect(repo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('trim le nom', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createRenameEnclosHandler(state, repo)({ type: 'rename-enclos', enclosId: 1, name: ' Paddock ' });
|
||||
expect(state.enclos[0]!.name).toBe('Paddock');
|
||||
});
|
||||
|
||||
it('ignore si le nom est vide ou whitespace', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createRenameEnclosHandler(state, repo)({ type: 'rename-enclos', enclosId: 1, name: ' ' });
|
||||
expect(state.enclos[0]!.name).toBe('Enclos 1');
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignore si l\'enclos n\'existe pas', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createRenameEnclosHandler(state, repo)({ type: 'rename-enclos', enclosId: 999, name: 'Test' });
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('NouvelleFournee', () => {
|
||||
it('réinitialise l\'enclos avec une DD fraîche et jauges à 0', () => {
|
||||
const state = makeState();
|
||||
const enc = state.enclos[0]!;
|
||||
// Simuler un état "usé"
|
||||
enc.gaugeLevels.baffeur = 80000;
|
||||
enc.alerted['foudroyeur'] = true;
|
||||
enc.timer = {
|
||||
running: true, startTime: Date.now() - 1000, pausedAt: null, pausedMs: 0,
|
||||
snapGauges: { baffeur: 80000 }, snapStats: {}, gaugeRecharges: {},
|
||||
};
|
||||
const repo = makeRepo();
|
||||
createNouvelleFourneeHandler(state, repo)({ type: 'nouvelle-fournee', enclosId: 1 });
|
||||
|
||||
expect(enc.timer.running).toBe(false);
|
||||
expect(enc.timer.startTime).toBeNull();
|
||||
expect(enc.timer.gaugeRecharges).toEqual({});
|
||||
expect(enc.alerted).toEqual({});
|
||||
expect(enc.gaugeLevels.baffeur).toBe(0);
|
||||
expect(enc.gaugeLevels.foudroyeur).toBe(0);
|
||||
expect(enc.dragodindes).toHaveLength(1);
|
||||
expect(enc.dragodindes[0]!.id).toBe(1);
|
||||
expect(enc.dragodindes[0]!.stats.endurance).toBe(0);
|
||||
expect(enc.nextDdId).toBe(2);
|
||||
expect(repo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignore si l\'enclos n\'existe pas', () => {
|
||||
const state = makeState();
|
||||
const repo = makeRepo();
|
||||
createNouvelleFourneeHandler(state, repo)({ type: 'nouvelle-fournee', enclosId: 999 });
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
673
tests/unit/application/queries.test.ts
Normal file
673
tests/unit/application/queries.test.ts
Normal file
@ -0,0 +1,673 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { AppState } from '@domain/ports/StateRepository';
|
||||
import { createEnclos, addDragodinde } from '@domain/entities/Enclos';
|
||||
import { createGetDashboardHandler } from '@application/queries/GetDashboard';
|
||||
import { createGetEnclosDetailHandler } from '@application/queries/GetEnclosDetail';
|
||||
import { createGetTimerStateHandler } from '@application/queries/GetTimerState';
|
||||
import { createGetBreedingOptionsHandler } from '@application/queries/GetBreedingOptions';
|
||||
import { createGetReapproTreeHandler } from '@application/queries/GetReapproTree';
|
||||
import { createGetInventaireHandler } from '@application/queries/GetInventaire';
|
||||
import { createGetSettingsHandler } from '@application/queries/GetSettings';
|
||||
import { createGetStatisticsHandler, TOTAL_RACES } from '@application/queries/GetStatistics';
|
||||
import { createGetWorkflowsHandler } from '@application/queries/GetWorkflows';
|
||||
|
||||
function makeState(): AppState {
|
||||
let enc = createEnclos(1);
|
||||
enc = addDragodinde(enc);
|
||||
return {
|
||||
enclos: [enc], activeId: 1, nextEnclosId: 2,
|
||||
alarmSound: 'arpege', notifsEnabled: true, ntfyTopic: '',
|
||||
archivedStats: [], inventaire: {}, workflows: [],
|
||||
accouplements: [
|
||||
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 5, babiesObtained: 3, date: '2026-01-01' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('GetDashboard', () => {
|
||||
it('aggregates stats', () => {
|
||||
const state = makeState();
|
||||
const handler = createGetDashboardHandler(state);
|
||||
const r = handler({ type: 'get-dashboard' });
|
||||
expect(r.totalCouples).toBe(5);
|
||||
expect(r.totalBabies).toBe(3);
|
||||
expect(r.enclosSummaries).toHaveLength(1);
|
||||
expect(r.successRate).toBe(60);
|
||||
expect(r.raceBreakdown['Dorée et Rousse']).toBe(3);
|
||||
});
|
||||
|
||||
it('handles empty state', () => {
|
||||
const state = makeState();
|
||||
state.accouplements = [];
|
||||
const r = createGetDashboardHandler(state)({ type: 'get-dashboard' });
|
||||
expect(r.totalCouples).toBe(0);
|
||||
expect(r.successRate).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetEnclosDetail', () => {
|
||||
it('returns enclos by id', () => {
|
||||
const state = makeState();
|
||||
const handler = createGetEnclosDetailHandler(state);
|
||||
const r = handler({ type: 'get-enclos-detail', enclosId: 1 });
|
||||
expect(r).not.toBeNull();
|
||||
expect(r!.id).toBe(1);
|
||||
});
|
||||
|
||||
it('returns null for unknown id', () => {
|
||||
const state = makeState();
|
||||
const r = createGetEnclosDetailHandler(state)({ type: 'get-enclos-detail', enclosId: 999 });
|
||||
expect(r).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetTimerState', () => {
|
||||
it('returns timer state', () => {
|
||||
const state = makeState();
|
||||
const r = createGetTimerStateHandler(state)({ type: 'get-timer-state', enclosId: 1 });
|
||||
expect(r).not.toBeNull();
|
||||
expect(r!.running).toBe(false);
|
||||
});
|
||||
|
||||
it('returns null for unknown enclos', () => {
|
||||
const state = makeState();
|
||||
const r = createGetTimerStateHandler(state)({ type: 'get-timer-state', enclosId: 999 });
|
||||
expect(r).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetBreedingOptions', () => {
|
||||
it('returns partners for Rousse', () => {
|
||||
const handler = createGetBreedingOptionsHandler();
|
||||
const r = handler({ type: 'get-breeding-options', race: 'Rousse' });
|
||||
expect(r.partners.length).toBeGreaterThan(0);
|
||||
expect(r.partners.some(p => p.partner === 'Dorée')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetReapproTree', () => {
|
||||
it('computes breeding plan', () => {
|
||||
const handler = createGetReapproTreeHandler();
|
||||
const r = handler({ type: 'get-reappro-tree', target: 'Dorée et Rousse', qty: 4, repro: {}, inverted: {} });
|
||||
expect(r.steps).toHaveLength(1);
|
||||
expect(r.totalGen1).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetInventaire', () => {
|
||||
it('retourne le stock brut de l\'inventaire', () => {
|
||||
const state = makeState();
|
||||
state.inventaire = { Rousse: { m: 1, f: 0 }, Dorée: { m: 0, f: 1 } };
|
||||
const handler = createGetInventaireHandler(state);
|
||||
const r = handler({ type: 'get-inventaire' });
|
||||
expect(r).toEqual({ Rousse: { m: 1, f: 0 }, Dorée: { m: 0, f: 1 } });
|
||||
});
|
||||
|
||||
it('inventaire vide retourne objet vide', () => {
|
||||
const state = makeState();
|
||||
const r = createGetInventaireHandler(state)({ type: 'get-inventaire' });
|
||||
expect(r).toEqual({});
|
||||
});
|
||||
|
||||
it('reflète les changements de state.inventaire en temps réel', () => {
|
||||
const state = makeState();
|
||||
const handler = createGetInventaireHandler(state);
|
||||
expect(handler({ type: 'get-inventaire' })).toEqual({});
|
||||
state.inventaire = { Amande: { m: 3, f: 2 } };
|
||||
expect(handler({ type: 'get-inventaire' })).toEqual({ Amande: { m: 3, f: 2 } });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Helpers pour les dates relatives ──────────────────────────────
|
||||
function daysAgo(n: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - n);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function dayOfWeekName(dateStr: string): string {
|
||||
const names = ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi'];
|
||||
const d = new Date(dateStr + 'T12:00:00');
|
||||
return names[d.getDay()];
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
// GetDashboard — archivedStats path (lignes 44-51)
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('GetDashboard — archivedStats', () => {
|
||||
it('agrège accouplements + archivedStats', () => {
|
||||
const state = makeState();
|
||||
state.archivedStats = [
|
||||
{ baby: 'Ebène', couples: 10, babiesObtained: 4 },
|
||||
{ baby: 'Dorée et Rousse', couples: 3, babiesObtained: 1 },
|
||||
];
|
||||
const r = createGetDashboardHandler(state)({ type: 'get-dashboard' });
|
||||
// accouplements: 5 couples, 3 babies + archived: 13 couples, 5 babies
|
||||
expect(r.totalCouples).toBe(18);
|
||||
expect(r.totalBabies).toBe(8);
|
||||
expect(r.raceBreakdown['Ebène']).toBe(4);
|
||||
expect(r.raceBreakdown['Dorée et Rousse']).toBe(4); // 3 + 1
|
||||
expect(r.successRate).toBe(44); // Math.round(8/18*100)
|
||||
});
|
||||
|
||||
it('ignore les entrées archivedStats sans baby', () => {
|
||||
const state = makeState();
|
||||
state.archivedStats = [
|
||||
{ couples: 10, babiesObtained: 4 }, // pas de baby
|
||||
];
|
||||
const r = createGetDashboardHandler(state)({ type: 'get-dashboard' });
|
||||
expect(r.totalCouples).toBe(5);
|
||||
expect(r.totalBabies).toBe(3);
|
||||
});
|
||||
|
||||
it('gère les champs manquants dans archivedStats', () => {
|
||||
const state = makeState();
|
||||
state.accouplements = [];
|
||||
state.archivedStats = [
|
||||
{ baby: 'Indigo' }, // couples et babiesObtained absents → default 0
|
||||
];
|
||||
const r = createGetDashboardHandler(state)({ type: 'get-dashboard' });
|
||||
expect(r.totalCouples).toBe(0);
|
||||
expect(r.totalBabies).toBe(0);
|
||||
expect(r.raceBreakdown['Indigo']).toBe(0);
|
||||
expect(r.successRate).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
// GetSettings
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('GetSettings', () => {
|
||||
it('retourne les paramètres courants', () => {
|
||||
const state = makeState();
|
||||
const r = createGetSettingsHandler(state)({ type: 'get-settings' });
|
||||
expect(r.alarmSound).toBe('arpege');
|
||||
expect(r.notifsEnabled).toBe(true);
|
||||
expect(r.ntfyTopic).toBe('');
|
||||
});
|
||||
|
||||
it('reflète les changements de paramètres en temps réel', () => {
|
||||
const state = makeState();
|
||||
const handler = createGetSettingsHandler(state);
|
||||
state.alarmSound = 'gong';
|
||||
state.notifsEnabled = false;
|
||||
state.ntfyTopic = 'mon-topic';
|
||||
const r = handler({ type: 'get-settings' });
|
||||
expect(r.alarmSound).toBe('gong');
|
||||
expect(r.notifsEnabled).toBe(false);
|
||||
expect(r.ntfyTopic).toBe('mon-topic');
|
||||
});
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
// GetWorkflows
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('GetWorkflows', () => {
|
||||
it('retourne la liste des workflows', () => {
|
||||
const state = makeState();
|
||||
const wf = {
|
||||
id: 1, name: 'Test WF', target: 'Ebène', qty: 2, createdAt: Date.now(),
|
||||
materials: [{ name: 'Rousse', needed: 4, done: 2 }],
|
||||
steps: [{ gen: 2, crossings: [{ race: 'Dorée et Rousse', needed: 2, parentA: 'Dorée', parentB: 'Rousse', couples: 2, repro: 1, done: 1 }] }],
|
||||
};
|
||||
state.workflows = [wf];
|
||||
const r = createGetWorkflowsHandler(state)({ type: 'get-workflows' });
|
||||
expect(r).toHaveLength(1);
|
||||
expect(r[0]).toBe(wf);
|
||||
});
|
||||
|
||||
it('retourne un tableau vide quand aucun workflow', () => {
|
||||
const state = makeState();
|
||||
const r = createGetWorkflowsHandler(state)({ type: 'get-workflows' });
|
||||
expect(r).toEqual([]);
|
||||
});
|
||||
|
||||
it('reflète les changements en temps réel', () => {
|
||||
const state = makeState();
|
||||
const handler = createGetWorkflowsHandler(state);
|
||||
expect(handler({ type: 'get-workflows' })).toEqual([]);
|
||||
state.workflows = [{ id: 42, name: 'WF2', target: 'Indigo', qty: 1, createdAt: 0, materials: [], steps: [] }];
|
||||
expect(handler({ type: 'get-workflows' })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
// GetStatistics
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('GetStatistics', () => {
|
||||
// ── Historique vide ────────────────────────────────────────────
|
||||
|
||||
it('historique vide retourne des KPI à zéro', () => {
|
||||
const state = makeState();
|
||||
state.accouplements = [];
|
||||
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
|
||||
expect(r.totalBabies.value).toBe(0);
|
||||
expect(r.totalCouples.value).toBe(0);
|
||||
expect(r.successRate.value).toBe(0);
|
||||
expect(r.racesCount.value).toBe(0);
|
||||
expect(r.raceShares).toEqual([]);
|
||||
expect(r.raceSuccessRates).toEqual([]);
|
||||
expect(r.bestCouples).toEqual([]);
|
||||
expect(r.genBreakdown).toEqual([]);
|
||||
expect(r.weekdayActivity).toHaveLength(7);
|
||||
expect(r.weekdayActivity.every(w => w.count === 0)).toBe(true);
|
||||
});
|
||||
|
||||
// ── days=0 → tout l'historique, delta=null ──────────────────
|
||||
|
||||
it('days=0 retourne tout l\'historique sans delta', () => {
|
||||
const state = makeState();
|
||||
state.accouplements = [
|
||||
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 10, babiesObtained: 6, date: '2025-06-15' },
|
||||
{ parentA: 'Amande', parentB: 'Dorée', baby: 'Amande et Dorée', gen: 2, couples: 8, babiesObtained: 3, date: '2025-01-10' },
|
||||
];
|
||||
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
|
||||
expect(r.days).toBe(0);
|
||||
expect(r.totalBabies).toEqual({ value: 9, delta: null });
|
||||
expect(r.totalCouples).toEqual({ value: 18, delta: null });
|
||||
expect(r.successRate).toEqual({ value: 50, delta: null });
|
||||
expect(r.racesCount).toEqual({ value: 2, delta: null });
|
||||
});
|
||||
|
||||
// ── Exclusion Gen 1 ────────────────────────────────────────────
|
||||
|
||||
it('exclut les entrées Gen 1 (Rousse, Dorée, Amande)', () => {
|
||||
const state = makeState();
|
||||
state.accouplements = [
|
||||
{ parentA: 'X', parentB: 'Y', baby: 'Rousse', gen: 1, couples: 100, babiesObtained: 50, date: daysAgo(1) },
|
||||
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 4, babiesObtained: 2, date: daysAgo(1) },
|
||||
];
|
||||
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
|
||||
expect(r.totalCouples.value).toBe(4);
|
||||
expect(r.totalBabies.value).toBe(2);
|
||||
});
|
||||
|
||||
it('exclut les entrées dont le baby est Gen 1 même si gen != 1', () => {
|
||||
const state = makeState();
|
||||
state.accouplements = [
|
||||
{ parentA: 'A', parentB: 'B', baby: 'Amande', gen: 0, couples: 20, babiesObtained: 10, date: daysAgo(1) },
|
||||
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 5, babiesObtained: 3, date: daysAgo(1) },
|
||||
];
|
||||
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
|
||||
expect(r.totalCouples.value).toBe(5);
|
||||
expect(r.totalBabies.value).toBe(3);
|
||||
});
|
||||
|
||||
// ── Filtrage par période avec delta ─────────────────────────
|
||||
|
||||
it('days=7 filtre la période courante et calcule le delta', () => {
|
||||
const state = makeState();
|
||||
state.accouplements = [
|
||||
// Période courante (7 derniers jours)
|
||||
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 10, babiesObtained: 6, date: daysAgo(2) },
|
||||
// Période précédente (7 à 14 jours)
|
||||
{ parentA: 'Amande', parentB: 'Dorée', baby: 'Amande et Dorée', gen: 2, couples: 8, babiesObtained: 2, date: daysAgo(10) },
|
||||
// Trop ancien (hors des deux fenêtres)
|
||||
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 100, babiesObtained: 50, date: '2020-01-01' },
|
||||
];
|
||||
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 7 });
|
||||
expect(r.days).toBe(7);
|
||||
expect(r.totalBabies.value).toBe(6);
|
||||
expect(r.totalBabies.delta).toBe(6 - 2); // cur - prev
|
||||
expect(r.totalCouples.value).toBe(10);
|
||||
expect(r.totalCouples.delta).toBe(10 - 8);
|
||||
});
|
||||
|
||||
// ── Default days=30 ─────────────────────────────────────────
|
||||
|
||||
it('days par défaut est 30', () => {
|
||||
const state = makeState();
|
||||
state.accouplements = [];
|
||||
const r = createGetStatisticsHandler(state)({ type: 'get-statistics' });
|
||||
expect(r.days).toBe(30);
|
||||
});
|
||||
|
||||
// ── archivedStats agrégation ──────────────────────────────────
|
||||
|
||||
it('inclut les archivedStats dans les statistiques', () => {
|
||||
const state = makeState();
|
||||
state.accouplements = [];
|
||||
state.archivedStats = [
|
||||
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 7, babiesObtained: 4, date: daysAgo(1) },
|
||||
];
|
||||
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
|
||||
expect(r.totalCouples.value).toBe(7);
|
||||
expect(r.totalBabies.value).toBe(4);
|
||||
});
|
||||
|
||||
it('exclut les archivedStats Gen 1', () => {
|
||||
const state = makeState();
|
||||
state.accouplements = [];
|
||||
state.archivedStats = [
|
||||
{ baby: 'Rousse', gen: 1, couples: 50, babiesObtained: 25, date: daysAgo(1) },
|
||||
{ baby: 'Dorée et Rousse', gen: 2, couples: 3, babiesObtained: 1, date: daysAgo(1) },
|
||||
];
|
||||
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
|
||||
expect(r.totalCouples.value).toBe(3);
|
||||
expect(r.totalBabies.value).toBe(1);
|
||||
});
|
||||
|
||||
it('archivedStats avec champs manquants utilise des défauts', () => {
|
||||
const state = makeState();
|
||||
state.accouplements = [];
|
||||
state.archivedStats = [
|
||||
{ baby: 'Ebène' }, // gen, couples, babiesObtained, date, parentA, parentB absents
|
||||
];
|
||||
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
|
||||
// Ebène est Gen 3, ne sera pas exclu
|
||||
expect(r.totalCouples.value).toBe(0);
|
||||
expect(r.totalBabies.value).toBe(0);
|
||||
});
|
||||
|
||||
it('ignore les archivedStats sans baby', () => {
|
||||
const state = makeState();
|
||||
state.accouplements = [];
|
||||
state.archivedStats = [
|
||||
{ couples: 10, babiesObtained: 5 },
|
||||
];
|
||||
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
|
||||
expect(r.totalCouples.value).toBe(0);
|
||||
expect(r.totalBabies.value).toBe(0);
|
||||
});
|
||||
|
||||
// ── Répartition des races (raceShares) ─────────────────────
|
||||
|
||||
it('calcule la répartition des races triée par count décroissant', () => {
|
||||
const state = makeState();
|
||||
state.accouplements = [
|
||||
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 10, babiesObtained: 7, date: daysAgo(1) },
|
||||
{ parentA: 'Amande', parentB: 'Dorée', baby: 'Amande et Dorée', gen: 2, couples: 5, babiesObtained: 3, date: daysAgo(1) },
|
||||
];
|
||||
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
|
||||
expect(r.raceShares).toHaveLength(2);
|
||||
expect(r.raceShares[0].race).toBe('Dorée et Rousse');
|
||||
expect(r.raceShares[0].count).toBe(7);
|
||||
expect(r.raceShares[0].pct).toBe(70); // 7/10*100
|
||||
expect(r.raceShares[1].race).toBe('Amande et Dorée');
|
||||
expect(r.raceShares[1].count).toBe(3);
|
||||
expect(r.raceShares[1].pct).toBe(30);
|
||||
});
|
||||
|
||||
// ── Taux de réussite par race ──────────────────────────────
|
||||
|
||||
it('calcule le taux de réussite par race', () => {
|
||||
const state = makeState();
|
||||
state.accouplements = [
|
||||
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 10, babiesObtained: 8, date: daysAgo(1) },
|
||||
{ parentA: 'Amande', parentB: 'Dorée', baby: 'Amande et Dorée', gen: 2, couples: 10, babiesObtained: 3, date: daysAgo(1) },
|
||||
];
|
||||
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
|
||||
expect(r.raceSuccessRates).toHaveLength(2);
|
||||
// Trié par rate décroissant
|
||||
expect(r.raceSuccessRates[0].race).toBe('Dorée et Rousse');
|
||||
expect(r.raceSuccessRates[0].rate).toBe(80);
|
||||
expect(r.raceSuccessRates[1].race).toBe('Amande et Dorée');
|
||||
expect(r.raceSuccessRates[1].rate).toBe(30);
|
||||
});
|
||||
|
||||
// ── Meilleurs couples ──────────────────────────────────────
|
||||
|
||||
it('calcule les meilleurs couples et normalise l\'ordre A|B', () => {
|
||||
const state = makeState();
|
||||
state.accouplements = [
|
||||
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 5, babiesObtained: 4, date: daysAgo(1) },
|
||||
{ parentA: 'Dorée', parentB: 'Rousse', baby: 'Dorée et Rousse', gen: 2, couples: 5, babiesObtained: 3, date: daysAgo(2) },
|
||||
];
|
||||
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
|
||||
// Les deux entrées doivent être fusionnées (clé normalisée)
|
||||
expect(r.bestCouples).toHaveLength(1);
|
||||
expect(r.bestCouples[0].couples).toBe(10);
|
||||
expect(r.bestCouples[0].babies).toBe(7);
|
||||
expect(r.bestCouples[0].rate).toBe(70);
|
||||
});
|
||||
|
||||
it('ignore les couples sans parentA ou parentB', () => {
|
||||
const state = makeState();
|
||||
state.accouplements = [
|
||||
{ parentA: '', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 5, babiesObtained: 3, date: daysAgo(1) },
|
||||
];
|
||||
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
|
||||
expect(r.bestCouples).toEqual([]);
|
||||
});
|
||||
|
||||
it('limite les meilleurs couples à 10', () => {
|
||||
const state = makeState();
|
||||
state.accouplements = [];
|
||||
// Créer 12 couples distincts
|
||||
const races = [
|
||||
'Dorée et Rousse', 'Amande et Rousse', 'Amande et Dorée',
|
||||
'Ebène', 'Indigo',
|
||||
'Indigo et Rousse', 'Ebène et Rousse', 'Amande et Indigo',
|
||||
'Amande et Ebène', 'Dorée et Indigo', 'Dorée et Ebène',
|
||||
'Ebène et Indigo',
|
||||
];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
state.accouplements.push({
|
||||
parentA: `ParentA${i}`, parentB: `ParentB${i}`, baby: races[i],
|
||||
gen: i < 3 ? 2 : i < 5 ? 3 : 4,
|
||||
couples: 5, babiesObtained: 3, date: daysAgo(1),
|
||||
});
|
||||
}
|
||||
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
|
||||
expect(r.bestCouples).toHaveLength(10);
|
||||
});
|
||||
|
||||
// ── Répartition par génération ──────────────────────────────
|
||||
|
||||
it('calcule la répartition par génération', () => {
|
||||
const state = makeState();
|
||||
state.accouplements = [
|
||||
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 10, babiesObtained: 5, date: daysAgo(1) },
|
||||
{ parentA: 'Amande', parentB: 'Dorée', baby: 'Amande et Dorée', gen: 2, couples: 8, babiesObtained: 3, date: daysAgo(1) },
|
||||
{ parentA: 'Amande et Dorée', parentB: 'Dorée et Rousse', baby: 'Ebène', gen: 3, couples: 6, babiesObtained: 2, date: daysAgo(1) },
|
||||
];
|
||||
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
|
||||
expect(r.genBreakdown).toHaveLength(2);
|
||||
const gen2 = r.genBreakdown.find(g => g.gen === 2)!;
|
||||
const gen3 = r.genBreakdown.find(g => g.gen === 3)!;
|
||||
expect(gen2.babies).toBe(8);
|
||||
expect(gen2.couples).toBe(18);
|
||||
expect(gen2.races).toBe(2);
|
||||
expect(gen3.babies).toBe(2);
|
||||
expect(gen3.couples).toBe(6);
|
||||
expect(gen3.races).toBe(1);
|
||||
});
|
||||
|
||||
it('genBreakdown trié par gen croissant', () => {
|
||||
const state = makeState();
|
||||
state.accouplements = [
|
||||
{ parentA: 'A', parentB: 'B', baby: 'Ebène', gen: 3, couples: 5, babiesObtained: 2, date: daysAgo(1) },
|
||||
{ parentA: 'C', parentB: 'D', baby: 'Dorée et Rousse', gen: 2, couples: 5, babiesObtained: 3, date: daysAgo(1) },
|
||||
];
|
||||
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
|
||||
expect(r.genBreakdown[0].gen).toBe(2);
|
||||
expect(r.genBreakdown[1].gen).toBe(3);
|
||||
});
|
||||
|
||||
// ── Races manquantes ────────────────────────────────────────
|
||||
|
||||
it('calcule les races manquantes (exclut Gen 1 et races obtenues)', () => {
|
||||
const state = makeState();
|
||||
state.accouplements = [
|
||||
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 5, babiesObtained: 3, date: daysAgo(1) },
|
||||
];
|
||||
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
|
||||
// Dorée et Rousse obtenue → ne doit pas apparaître
|
||||
expect(r.missingRaces.find(m => m.name === 'Dorée et Rousse')).toBeUndefined();
|
||||
// Les Gen 1 ne doivent pas apparaître
|
||||
expect(r.missingRaces.find(m => m.name === 'Rousse')).toBeUndefined();
|
||||
expect(r.missingRaces.find(m => m.name === 'Dorée')).toBeUndefined();
|
||||
expect(r.missingRaces.find(m => m.name === 'Amande')).toBeUndefined();
|
||||
// Races non obtenues doivent apparaître
|
||||
expect(r.missingRaces.find(m => m.name === 'Amande et Rousse')).toBeDefined();
|
||||
expect(r.missingRaces.length).toBe(TOTAL_RACES - 1); // 63 - 1 obtenue
|
||||
});
|
||||
|
||||
it('races manquantes utilise all (pas seulement la période courante)', () => {
|
||||
const state = makeState();
|
||||
state.accouplements = [
|
||||
// Très ancien mais obtenu
|
||||
{ parentA: 'A', parentB: 'B', baby: 'Ebène', gen: 3, couples: 5, babiesObtained: 1, date: '2020-01-01' },
|
||||
// Récent
|
||||
{ parentA: 'C', parentB: 'D', baby: 'Dorée et Rousse', gen: 2, couples: 5, babiesObtained: 2, date: daysAgo(1) },
|
||||
];
|
||||
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 7 });
|
||||
// Ebène obtenue même si hors période courante → exclue des manquantes
|
||||
expect(r.missingRaces.find(m => m.name === 'Ebène')).toBeUndefined();
|
||||
expect(r.missingRaces.length).toBe(TOTAL_RACES - 2);
|
||||
});
|
||||
|
||||
it('races manquantes triées par gen croissant puis nom', () => {
|
||||
const state = makeState();
|
||||
state.accouplements = [];
|
||||
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
|
||||
// Vérifier le tri
|
||||
for (let i = 1; i < r.missingRaces.length; i++) {
|
||||
const prev = r.missingRaces[i - 1];
|
||||
const cur = r.missingRaces[i];
|
||||
if (prev.gen === cur.gen) {
|
||||
expect(prev.name.localeCompare(cur.name)).toBeLessThanOrEqual(0);
|
||||
} else {
|
||||
expect(prev.gen).toBeLessThan(cur.gen);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Naissances par jour (dailyBirths) ──────────────────────
|
||||
|
||||
it('génère 30 jours de naissances quand days=0', () => {
|
||||
const state = makeState();
|
||||
state.accouplements = [];
|
||||
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
|
||||
expect(r.dailyBirths).toHaveLength(30);
|
||||
});
|
||||
|
||||
it('génère N jours de naissances quand days=N', () => {
|
||||
const state = makeState();
|
||||
state.accouplements = [];
|
||||
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 7 });
|
||||
expect(r.dailyBirths).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('dailyBirths contient les naissances du jour', () => {
|
||||
const today = daysAgo(0);
|
||||
const state = makeState();
|
||||
state.accouplements = [
|
||||
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 5, babiesObtained: 3, date: today },
|
||||
];
|
||||
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 7 });
|
||||
const todayEntry = r.dailyBirths.find(d => d.date === today);
|
||||
expect(todayEntry).toBeDefined();
|
||||
expect(todayEntry!.count).toBe(3);
|
||||
});
|
||||
|
||||
it('dailyBirths remplit 0 pour les jours sans naissance', () => {
|
||||
const state = makeState();
|
||||
state.accouplements = [];
|
||||
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 7 });
|
||||
expect(r.dailyBirths.every(d => d.count === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('dailyBirths format label DD/MM', () => {
|
||||
const state = makeState();
|
||||
state.accouplements = [];
|
||||
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 7 });
|
||||
for (const d of r.dailyBirths) {
|
||||
expect(d.label).toMatch(/^\d{2}\/\d{2}$/);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Activité par jour de la semaine ─────────────────────────
|
||||
|
||||
it('activité par jour de la semaine commence lundi et finit dimanche', () => {
|
||||
const state = makeState();
|
||||
state.accouplements = [];
|
||||
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
|
||||
expect(r.weekdayActivity).toHaveLength(7);
|
||||
expect(r.weekdayActivity[0].day).toBe('Lundi');
|
||||
expect(r.weekdayActivity[6].day).toBe('Dimanche');
|
||||
});
|
||||
|
||||
it('activité par jour de la semaine comptabilise les naissances', () => {
|
||||
const today = daysAgo(0);
|
||||
const todayName = dayOfWeekName(today);
|
||||
const state = makeState();
|
||||
state.accouplements = [
|
||||
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 5, babiesObtained: 7, date: today },
|
||||
];
|
||||
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
|
||||
const dayEntry = r.weekdayActivity.find(w => w.day === todayName);
|
||||
expect(dayEntry).toBeDefined();
|
||||
expect(dayEntry!.count).toBe(7);
|
||||
});
|
||||
|
||||
// ── Entrées sans date valide ────────────────────────────────
|
||||
|
||||
it('entrées sans date ne cassent pas le weekdayActivity ni dailyBirths', () => {
|
||||
const state = makeState();
|
||||
state.accouplements = [
|
||||
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 5, babiesObtained: 3, date: '' },
|
||||
];
|
||||
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
|
||||
// Pas de crash, les compteurs weekday sont à 0
|
||||
expect(r.weekdayActivity).toHaveLength(7);
|
||||
expect(r.totalBabies.value).toBe(3); // toujours compté dans le total
|
||||
});
|
||||
|
||||
// ── racesCount ne compte que les races avec babiesObtained > 0 ──
|
||||
|
||||
it('racesCount ne compte que les races avec au moins un bébé', () => {
|
||||
const state = makeState();
|
||||
state.accouplements = [
|
||||
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 10, babiesObtained: 5, date: daysAgo(1) },
|
||||
{ parentA: 'Amande', parentB: 'Dorée', baby: 'Amande et Dorée', gen: 2, couples: 8, babiesObtained: 0, date: daysAgo(1) },
|
||||
];
|
||||
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
|
||||
expect(r.racesCount.value).toBe(1); // Seule Dorée et Rousse a des bébés
|
||||
});
|
||||
|
||||
// ── raceSuccessRates avec couples=0 ─────────────────────────
|
||||
|
||||
it('raceSuccessRates rate=0 quand couples=0', () => {
|
||||
const state = makeState();
|
||||
state.accouplements = [
|
||||
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 0, babiesObtained: 0, date: daysAgo(1) },
|
||||
];
|
||||
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
|
||||
const dr = r.raceSuccessRates.find(rs => rs.race === 'Dorée et Rousse');
|
||||
expect(dr).toBeDefined();
|
||||
expect(dr!.rate).toBe(0);
|
||||
});
|
||||
|
||||
// ── genBreakdown ne compte les races que si babiesObtained > 0 ──
|
||||
|
||||
it('genBreakdown.races ne compte que les races avec bébés', () => {
|
||||
const state = makeState();
|
||||
state.accouplements = [
|
||||
{ parentA: 'A', parentB: 'B', baby: 'Dorée et Rousse', gen: 2, couples: 10, babiesObtained: 5, date: daysAgo(1) },
|
||||
{ parentA: 'C', parentB: 'D', baby: 'Amande et Dorée', gen: 2, couples: 10, babiesObtained: 0, date: daysAgo(1) },
|
||||
];
|
||||
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 0 });
|
||||
const gen2 = r.genBreakdown.find(g => g.gen === 2)!;
|
||||
expect(gen2.races).toBe(1); // Seule Dorée et Rousse
|
||||
});
|
||||
|
||||
// ── Delta successRate ──────────────────────────────────────
|
||||
|
||||
it('delta successRate calcule la différence entre périodes', () => {
|
||||
const state = makeState();
|
||||
state.accouplements = [
|
||||
// Période courante : 10 couples, 8 bébés → 80%
|
||||
{ parentA: 'Rousse', parentB: 'Dorée', baby: 'Dorée et Rousse', gen: 2, couples: 10, babiesObtained: 8, date: daysAgo(2) },
|
||||
// Période précédente : 10 couples, 5 bébés → 50%
|
||||
{ parentA: 'Amande', parentB: 'Dorée', baby: 'Amande et Dorée', gen: 2, couples: 10, babiesObtained: 5, date: daysAgo(10) },
|
||||
];
|
||||
const r = createGetStatisticsHandler(state)({ type: 'get-statistics', days: 7 });
|
||||
expect(r.successRate.value).toBe(80);
|
||||
expect(r.successRate.delta).toBe(30); // 80 - 50
|
||||
});
|
||||
});
|
||||
57
tests/unit/domain/BreedingService.test.ts
Normal file
57
tests/unit/domain/BreedingService.test.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { BreedingService } from '@domain/services/BreedingService';
|
||||
|
||||
describe('BreedingService', () => {
|
||||
const svc = new BreedingService();
|
||||
|
||||
it('deduces baby from two parents (order A)', () => {
|
||||
expect(svc.deduceBaby('Rousse', 'Dorée')).toBe('Dorée et Rousse');
|
||||
});
|
||||
|
||||
it('deduces baby from two parents (order B — bidirectional)', () => {
|
||||
expect(svc.deduceBaby('Dorée', 'Rousse')).toBe('Dorée et Rousse');
|
||||
});
|
||||
|
||||
it('returns null for incompatible parents', () => {
|
||||
expect(svc.deduceBaby('Rousse', 'Rousse')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for unknown parents', () => {
|
||||
expect(svc.deduceBaby('Unknown', 'Dorée')).toBeNull();
|
||||
});
|
||||
|
||||
it('lists compatible partners for Rousse', () => {
|
||||
const partners = svc.getCompatiblePartners('Rousse');
|
||||
expect(partners.length).toBeGreaterThan(0);
|
||||
expect(partners.some(p => p.partner === 'Dorée')).toBe(true);
|
||||
expect(partners.some(p => p.partner === 'Amande')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns empty array for unknown race', () => {
|
||||
expect(svc.getCompatiblePartners('Unknown')).toEqual([]);
|
||||
});
|
||||
|
||||
it('gets parents for a baby race', () => {
|
||||
const parents = svc.getParents('Dorée et Rousse');
|
||||
expect(parents).toEqual(['Rousse', 'Dorée']);
|
||||
});
|
||||
|
||||
it('returns null parents for base race', () => {
|
||||
expect(svc.getParents('Rousse')).toBeNull();
|
||||
});
|
||||
|
||||
it('gets generation of a race', () => {
|
||||
expect(svc.getGeneration('Rousse')).toBe(1);
|
||||
expect(svc.getGeneration('Dorée et Rousse')).toBe(2);
|
||||
});
|
||||
|
||||
it('all recipes are bidirectionally deducible', () => {
|
||||
// For every known recipe, both orderings should work
|
||||
const parents = svc.getParents('Amande et Dorée');
|
||||
expect(parents).not.toBeNull();
|
||||
if (parents) {
|
||||
expect(svc.deduceBaby(parents[0], parents[1])).toBe('Amande et Dorée');
|
||||
expect(svc.deduceBaby(parents[1], parents[0])).toBe('Amande et Dorée');
|
||||
}
|
||||
});
|
||||
});
|
||||
86
tests/unit/domain/Enclos.test.ts
Normal file
86
tests/unit/domain/Enclos.test.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createEnclos, addDragodinde, removeDragodinde, MAX_DD, MAX_ENCLOS, MAX_GAUGES } from '@domain/entities/Enclos';
|
||||
import { createDragodinde } from '@domain/entities/Dragodinde';
|
||||
import { createAccouplement } from '@domain/entities/Accouplement';
|
||||
|
||||
describe('Enclos', () => {
|
||||
it('creates with defaults', () => {
|
||||
const enc = createEnclos(1);
|
||||
expect(enc.id).toBe(1);
|
||||
expect(enc.name).toBe('Enclos 1');
|
||||
expect(enc.dragodindes).toHaveLength(0);
|
||||
expect(enc.activeGauges).toHaveLength(0);
|
||||
expect(enc.timer.running).toBe(false);
|
||||
expect(enc.gaugeLevels.baffeur).toBe(0);
|
||||
});
|
||||
|
||||
it('adds a dragodinde', () => {
|
||||
let enc = createEnclos(1);
|
||||
enc = addDragodinde(enc);
|
||||
expect(enc.dragodindes).toHaveLength(1);
|
||||
expect(enc.dragodindes[0]!.name).toBe('Dragodinde 1');
|
||||
expect(enc.nextDdId).toBe(2);
|
||||
});
|
||||
|
||||
it('increments DD id', () => {
|
||||
let enc = createEnclos(1);
|
||||
enc = addDragodinde(enc);
|
||||
enc = addDragodinde(enc);
|
||||
expect(enc.dragodindes[1]!.id).toBe(2);
|
||||
expect(enc.dragodindes[1]!.name).toBe('Dragodinde 2');
|
||||
});
|
||||
|
||||
it('limits to MAX_DD', () => {
|
||||
let enc = createEnclos(1);
|
||||
for (let i = 0; i < MAX_DD + 2; i++) enc = addDragodinde(enc);
|
||||
expect(enc.dragodindes).toHaveLength(MAX_DD);
|
||||
});
|
||||
|
||||
it('removes a dragodinde', () => {
|
||||
let enc = createEnclos(1);
|
||||
enc = addDragodinde(enc);
|
||||
const ddId = enc.dragodindes[0]!.id;
|
||||
enc = removeDragodinde(enc, ddId);
|
||||
expect(enc.dragodindes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('remove non-existent DD is no-op', () => {
|
||||
let enc = createEnclos(1);
|
||||
enc = addDragodinde(enc);
|
||||
enc = removeDragodinde(enc, 999);
|
||||
expect(enc.dragodindes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('constants are correct', () => {
|
||||
expect(MAX_DD).toBe(10);
|
||||
expect(MAX_ENCLOS).toBe(6);
|
||||
expect(MAX_GAUGES).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dragodinde', () => {
|
||||
it('creates with defaults', () => {
|
||||
const dd = createDragodinde(1);
|
||||
expect(dd.id).toBe(1);
|
||||
expect(dd.name).toBe('Dragodinde 1');
|
||||
expect(dd.gender).toBe('n');
|
||||
expect(dd.stats.serenite).toBe(0);
|
||||
expect(dd.stats.xp).toBe(1);
|
||||
expect(dd.targets.baffeur).toBe(-5000);
|
||||
expect(dd.sereniteTarget).toBeNull();
|
||||
expect(dd.levelTarget).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accouplement', () => {
|
||||
it('creates with all fields', () => {
|
||||
const a = createAccouplement('Rousse', 'Dorée', 'Dorée et Rousse', 2, 5, 3);
|
||||
expect(a.parentA).toBe('Rousse');
|
||||
expect(a.parentB).toBe('Dorée');
|
||||
expect(a.baby).toBe('Dorée et Rousse');
|
||||
expect(a.gen).toBe(2);
|
||||
expect(a.couples).toBe(5);
|
||||
expect(a.babiesObtained).toBe(3);
|
||||
expect(a.date).toBeTruthy();
|
||||
});
|
||||
});
|
||||
45
tests/unit/domain/EventBus.test.ts
Normal file
45
tests/unit/domain/EventBus.test.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { EventBus } from '@domain/events/EventBus';
|
||||
|
||||
describe('EventBus', () => {
|
||||
it('dispatches events to subscribers', () => {
|
||||
const bus = new EventBus();
|
||||
const handler = vi.fn();
|
||||
bus.on('timer-completed', handler);
|
||||
bus.emit({ type: 'timer-completed', enclosId: 1 });
|
||||
expect(handler).toHaveBeenCalledWith({ type: 'timer-completed', enclosId: 1 });
|
||||
});
|
||||
|
||||
it('ignores unsubscribed event types', () => {
|
||||
const bus = new EventBus();
|
||||
const handler = vi.fn();
|
||||
bus.on('timer-completed', handler);
|
||||
bus.emit({ type: 'accouplement-registered' });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('supports multiple handlers for same event', () => {
|
||||
const bus = new EventBus();
|
||||
const h1 = vi.fn();
|
||||
const h2 = vi.fn();
|
||||
bus.on('timer-completed', h1);
|
||||
bus.on('timer-completed', h2);
|
||||
bus.emit({ type: 'timer-completed' });
|
||||
expect(h1).toHaveBeenCalled();
|
||||
expect(h2).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('off removes handler', () => {
|
||||
const bus = new EventBus();
|
||||
const handler = vi.fn();
|
||||
bus.on('timer-completed', handler);
|
||||
bus.off('timer-completed', handler);
|
||||
bus.emit({ type: 'timer-completed' });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emit with no handlers does not throw', () => {
|
||||
const bus = new EventBus();
|
||||
expect(() => bus.emit({ type: 'timer-completed' })).not.toThrow();
|
||||
});
|
||||
});
|
||||
224
tests/unit/domain/GaugeCalculator.test.ts
Normal file
224
tests/unit/domain/GaugeCalculator.test.ts
Normal file
@ -0,0 +1,224 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { gainedIn, timeToGain, gaugeAfter, elapsed, computeGaugeState, TimerState } from '@domain/services/GaugeCalculator';
|
||||
import type { GaugeRecharge } from '@domain/services/GaugeCalculator';
|
||||
|
||||
describe('GaugeCalculator', () => {
|
||||
|
||||
describe('gainedIn', () => {
|
||||
it('tier 1 only: 100 pts in 100 sec', () => {
|
||||
// Level 100, 100 sec = 10 ticks, rate 10 → gain 100
|
||||
expect(gainedIn(100, 100)).toBe(100);
|
||||
});
|
||||
|
||||
it('zero seconds = zero gain', () => {
|
||||
expect(gainedIn(50000, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('level 0 = zero gain', () => {
|
||||
expect(gainedIn(0, 100)).toBe(0);
|
||||
});
|
||||
|
||||
it('crosses tier boundary 2→1', () => {
|
||||
// Level 40010, 10 sec = 1 tick. In tier 2 (40001-70000), rate 20.
|
||||
// But only 10 pts above 40000, so 10/20 = 0 full ticks at rate 20 → 0 gained from tier 2
|
||||
// Actually: a = 40010-40000 = 10, m = floor(10/20) = 0, u = 0. Move to tier 1.
|
||||
// Tier 1: g=40010 > 0, a=40010-0=40010... wait g is now 40010 still since u=0
|
||||
// Let me recalculate: g=40010, tier {lo:40000, r:20}: a=10, m=0, u=0. No drain.
|
||||
// Tier {lo:0, r:10}: g=40010 > 0, a=40010, m=4001, u=min(4001,1)=1, out=10
|
||||
// Actually g should still be 40010 since we didn't drain. Hmm but the loop says if g<=lo continue
|
||||
// g=40010 > 40000 → process. a=10, m=floor(10/20)=0, u=0.
|
||||
// g=40010 > 0 → process. a=40010, m=floor(40010/10)=4001, u=min(4001,1)=1, out=10.
|
||||
expect(gainedIn(40010, 10)).toBe(10);
|
||||
});
|
||||
|
||||
it('full tier 4 drain', () => {
|
||||
// Level 100000, 250 ticks needed to drain tier 4 (10000/40=250)
|
||||
// 250 ticks = 2500 sec
|
||||
expect(gainedIn(100000, 2500)).toBe(10000);
|
||||
});
|
||||
|
||||
it('large gauge drains through multiple tiers', () => {
|
||||
// Level 100000, 5000 sec = 500 ticks
|
||||
// Tier 4: 100000→90000 = 10000/40 = 250 ticks → gain 10000, tl=250
|
||||
// Tier 3: 90000→70000 = 20000/30 = 666 ticks, but only 250 left → 250*30 = 7500
|
||||
// Total = 17500
|
||||
expect(gainedIn(100000, 5000)).toBe(17500);
|
||||
});
|
||||
|
||||
it('negative level clamped to 0', () => {
|
||||
expect(gainedIn(-100, 100)).toBe(0);
|
||||
});
|
||||
|
||||
it('level above 100000 clamped', () => {
|
||||
expect(gainedIn(150000, 10)).toBe(40); // tier 4 rate
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeToGain', () => {
|
||||
it('zero points = zero time', () => {
|
||||
expect(timeToGain(50000, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('negative points = zero time', () => {
|
||||
expect(timeToGain(50000, -10)).toBe(0);
|
||||
});
|
||||
|
||||
it('tier 1: 100 pts needs 100 sec', () => {
|
||||
// Level 100, need 100 pts at rate 10 → 10 ticks → 100 sec
|
||||
expect(timeToGain(100, 100)).toBe(100);
|
||||
});
|
||||
|
||||
it('tier 2: 1000 pts from level 50000', () => {
|
||||
// Level 50000, tier 2 rate 20. 1000/20 = 50 ticks → 500 sec
|
||||
expect(timeToGain(50000, 1000)).toBe(500);
|
||||
});
|
||||
|
||||
it('returns Infinity if level is 0', () => {
|
||||
expect(timeToGain(0, 100)).toBe(Infinity);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gaugeAfter', () => {
|
||||
it('no time = same level', () => {
|
||||
expect(gaugeAfter(50000, 0)).toBe(50000);
|
||||
});
|
||||
|
||||
it('tier 1 full drain', () => {
|
||||
// Level 100, 100 sec = 10 ticks. 100/10=10 ticks needed. Exactly drains to 0.
|
||||
expect(gaugeAfter(100, 100)).toBe(0);
|
||||
});
|
||||
|
||||
it('does not go below 0', () => {
|
||||
expect(gaugeAfter(50, 1000)).toBe(0);
|
||||
});
|
||||
|
||||
it('partial drain in tier 2', () => {
|
||||
// Level 50000, 10 sec = 1 tick. Rate 20 at tier 2. 50000-20 = 49980
|
||||
expect(gaugeAfter(50000, 10)).toBe(49980);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeGaugeState', () => {
|
||||
const NO_RECHARGES: GaugeRecharge[] = [];
|
||||
|
||||
it('sans recharge ni cap : identique à gainedIn', () => {
|
||||
const el = 1000;
|
||||
const startGl = 50000;
|
||||
const { gained, curGl, effectiveEl } = computeGaugeState(startGl, NO_RECHARGES, Infinity, el);
|
||||
expect(gained).toBe(gainedIn(startGl, el));
|
||||
expect(curGl).toBe(gaugeAfter(startGl, el));
|
||||
expect(effectiveEl).toBe(el);
|
||||
});
|
||||
|
||||
it('cap à 0 dès le départ (stat déjà au max) → gel immédiat', () => {
|
||||
const { gained, effectiveEl } = computeGaugeState(90000, NO_RECHARGES, 0, 5000);
|
||||
expect(gained).toBe(0);
|
||||
expect(effectiveEl).toBe(0);
|
||||
});
|
||||
|
||||
it('cap atteint en cours de segment → gel précis', () => {
|
||||
// Tier 2 (50000), rate 20/tick. On veut 200 pts → 10 ticks → 100 sec.
|
||||
const { gained, effectiveEl } = computeGaugeState(50000, NO_RECHARGES, 200, 5000);
|
||||
expect(gained).toBe(200);
|
||||
expect(effectiveEl).toBe(100); // timeToGain(50000, 200) = 100 sec
|
||||
});
|
||||
|
||||
it('une recharge avant la fin, pas de cap → somme des deux segments', () => {
|
||||
// Seg1 : 50000, 100 sec → gainedIn(50000, 100) = 10 ticks × 20 = 200 pts
|
||||
// Recharge à 80000 à t=100
|
||||
// Seg2 : 80000, 100 sec → gainedIn(80000, 100) = 10 ticks × 30 = 300 pts
|
||||
// Total = 500 pts
|
||||
const recharges: GaugeRecharge[] = [{ atSec: 100, level: 80000 }];
|
||||
const { gained, effectiveEl } = computeGaugeState(50000, recharges, Infinity, 200);
|
||||
expect(gained).toBe(500);
|
||||
expect(effectiveEl).toBe(200);
|
||||
});
|
||||
|
||||
it('cap atteint dans le premier segment (avant la recharge)', () => {
|
||||
// Seg1 : 50000, cap=100 pts → gel à timeToGain(50000,100)=50 sec
|
||||
// Recharge à t=100 ne doit pas être prise en compte
|
||||
const recharges: GaugeRecharge[] = [{ atSec: 100, level: 90000 }];
|
||||
const { gained, effectiveEl } = computeGaugeState(50000, recharges, 100, 500);
|
||||
expect(gained).toBe(100);
|
||||
expect(effectiveEl).toBe(50); // gel avant la recharge
|
||||
});
|
||||
|
||||
it('cap atteint dans le deuxième segment (après une recharge)', () => {
|
||||
// Seg1 : 50000, 100 sec → 200 pts, cap=500 → pas encore atteint
|
||||
// Recharge à 80000 à t=100
|
||||
// Seg2 : 80000, cap restant=300 pts → timeToGain(80000,300)=100 sec → effectiveEl=100+100=200
|
||||
const recharges: GaugeRecharge[] = [{ atSec: 100, level: 80000 }];
|
||||
const { gained, effectiveEl } = computeGaugeState(50000, recharges, 500, 9999);
|
||||
expect(gained).toBe(500);
|
||||
expect(effectiveEl).toBe(200);
|
||||
});
|
||||
|
||||
it('plusieurs recharges successives', () => {
|
||||
// Seg1 : 10000 (tier1), 50 sec → 5 ticks × 10 = 50 pts
|
||||
// Recharge à 50000 à t=50
|
||||
// Seg2 : 50000 (tier2), 50 sec → 5 ticks × 20 = 100 pts
|
||||
// Recharge à 90000 à t=100
|
||||
// Seg3 : 90000 (tier3), 50 sec → 5 ticks × 30 = 150 pts
|
||||
// Total = 300 pts
|
||||
const recharges: GaugeRecharge[] = [
|
||||
{ atSec: 50, level: 50000 },
|
||||
{ atSec: 100, level: 90000 },
|
||||
];
|
||||
const { gained } = computeGaugeState(10000, recharges, Infinity, 150);
|
||||
expect(gained).toBe(300);
|
||||
});
|
||||
|
||||
it('recharge après le cap → le cap prime, recharge ignorée', () => {
|
||||
// Cap à 50 pts → gel à 50 sec (tier2, rate20, 50/20=2.5→3 ticks=30sec... wait)
|
||||
// timeToGain(50000, 50) = ceil(50/20)*10 = 3*10 = 30 sec
|
||||
// Recharge à t=100 → ignorée car gel à t=30
|
||||
const recharges: GaugeRecharge[] = [{ atSec: 100, level: 90000 }];
|
||||
const { gained, effectiveEl } = computeGaugeState(50000, recharges, 50, 9999);
|
||||
expect(gained).toBe(50);
|
||||
expect(effectiveEl).toBe(30);
|
||||
});
|
||||
|
||||
it('curGl reflète le niveau après le gel', () => {
|
||||
// Tier2 (50000), cap=200 pts → 10 ticks → 100 sec, gauge = 50000-10*20=49800
|
||||
const { curGl } = computeGaugeState(50000, NO_RECHARGES, 200, 9999);
|
||||
expect(curGl).toBe(49800);
|
||||
});
|
||||
|
||||
it('curGl reflète la recharge dans le deuxième segment', () => {
|
||||
// Seg1: 50000, 100sec → curGl après seg1 = gaugeAfter(50000,100)=49800 mais recharge à 80000
|
||||
// Seg2: 80000, no cap → curGl = gaugeAfter(80000, 100sec) = 80000-10*30=79700
|
||||
const recharges: GaugeRecharge[] = [{ atSec: 100, level: 80000 }];
|
||||
const { curGl } = computeGaugeState(50000, recharges, Infinity, 200);
|
||||
expect(curGl).toBe(gaugeAfter(80000, 100));
|
||||
});
|
||||
});
|
||||
|
||||
describe('elapsed', () => {
|
||||
it('no start time = 0', () => {
|
||||
const t: TimerState = { startTime: null, running: false, pausedAt: null, pausedMs: 0 };
|
||||
expect(elapsed(t)).toBe(0);
|
||||
});
|
||||
|
||||
it('running timer', () => {
|
||||
const now = Date.now();
|
||||
const t: TimerState = { startTime: now - 10000, running: true, pausedAt: null, pausedMs: 0 };
|
||||
const el = elapsed(t);
|
||||
expect(el).toBeGreaterThanOrEqual(9.9);
|
||||
expect(el).toBeLessThanOrEqual(10.5);
|
||||
});
|
||||
|
||||
it('paused timer', () => {
|
||||
const now = Date.now();
|
||||
const t: TimerState = { startTime: now - 10000, running: false, pausedAt: now - 5000, pausedMs: 0 };
|
||||
const el = elapsed(t);
|
||||
expect(el).toBeCloseTo(5, 0);
|
||||
});
|
||||
|
||||
it('paused timer with accumulated pause', () => {
|
||||
const now = Date.now();
|
||||
const t: TimerState = { startTime: now - 20000, running: false, pausedAt: now - 5000, pausedMs: 5000 };
|
||||
// elapsed = (pausedAt - startTime - pausedMs) / 1000 = (15000 - 5000) / 1000 = 10
|
||||
expect(elapsed(t)).toBeCloseTo(10, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
45
tests/unit/domain/GaugeType.test.ts
Normal file
45
tests/unit/domain/GaugeType.test.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { GaugeType, GAUGE_DEFS, STAT_DEFS, DEFAULT_TARGETS, targetRange } from '@domain/value-objects/GaugeType';
|
||||
|
||||
describe('GaugeType', () => {
|
||||
it('should have 6 gauge types', () => {
|
||||
const types: GaugeType[] = ['baffeur', 'caresseur', 'foudroyeur', 'abreuvoir', 'dragofesse', 'mangeoire'];
|
||||
expect(types).toHaveLength(6);
|
||||
types.forEach(t => expect(GAUGE_DEFS[t]).toBeDefined());
|
||||
});
|
||||
|
||||
it('baffeur decreases serenite', () => {
|
||||
expect(GAUGE_DEFS.baffeur.stat).toBe('serenite');
|
||||
expect(GAUGE_DEFS.baffeur.dir).toBe(-1);
|
||||
});
|
||||
|
||||
it('caresseur increases serenite', () => {
|
||||
expect(GAUGE_DEFS.caresseur.stat).toBe('serenite');
|
||||
expect(GAUGE_DEFS.caresseur.dir).toBe(1);
|
||||
});
|
||||
|
||||
it('mangeoire is XP type', () => {
|
||||
expect(GAUGE_DEFS.mangeoire.isXp).toBe(true);
|
||||
expect(GAUGE_DEFS.mangeoire.stat).toBe('xp');
|
||||
});
|
||||
|
||||
it('STAT_DEFS has 5 stats', () => {
|
||||
expect(Object.keys(STAT_DEFS)).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('targetRange for baffeur returns min:-5000 max:0', () => {
|
||||
expect(targetRange('baffeur')).toEqual({ min: -5000, max: 0 });
|
||||
});
|
||||
|
||||
it('targetRange for caresseur returns min:0 max:5000', () => {
|
||||
expect(targetRange('caresseur')).toEqual({ min: 0, max: 5000 });
|
||||
});
|
||||
|
||||
it('targetRange for mangeoire returns min:1 max:200', () => {
|
||||
expect(targetRange('mangeoire')).toEqual({ min: 1, max: 200 });
|
||||
});
|
||||
|
||||
it('targetRange for foudroyeur returns min:0 max:20000', () => {
|
||||
expect(targetRange('foudroyeur')).toEqual({ min: 0, max: 20000 });
|
||||
});
|
||||
});
|
||||
79
tests/unit/domain/InventaireCalculator.test.ts
Normal file
79
tests/unit/domain/InventaireCalculator.test.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { InventaireCalculator } from '@domain/services/InventaireCalculator';
|
||||
|
||||
describe('InventaireCalculator', () => {
|
||||
const calc = new InventaireCalculator();
|
||||
|
||||
it('empty inventory produces nothing', () => {
|
||||
const r = calc.compute({});
|
||||
expect(r.generations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('zero stock produces nothing', () => {
|
||||
const r = calc.compute({ 'Rousse': { m: 0, f: 0 } });
|
||||
expect(r.generations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('\u2642 Rousse + \u2640 Dor\u00e9e produces Dor\u00e9e et Rousse', () => {
|
||||
const r = calc.compute({
|
||||
'Rousse': { m: 1, f: 0 },
|
||||
'Dor\u00e9e': { m: 0, f: 1 },
|
||||
});
|
||||
expect(r.generations.length).toBeGreaterThanOrEqual(1);
|
||||
const gen2 = r.generations.find(g => g.gen === 2);
|
||||
expect(gen2).toBeDefined();
|
||||
expect(gen2!.crossings.some(c => c.name === 'Dor\u00e9e et Rousse')).toBe(true);
|
||||
});
|
||||
|
||||
it('\u2640 Rousse + \u2642 Dor\u00e9e also produces (reversed genders)', () => {
|
||||
const r = calc.compute({
|
||||
'Rousse': { m: 0, f: 1 },
|
||||
'Dor\u00e9e': { m: 1, f: 0 },
|
||||
});
|
||||
const gen2 = r.generations.find(g => g.gen === 2);
|
||||
expect(gen2).toBeDefined();
|
||||
expect(gen2!.crossings.some(c => c.name === 'Dor\u00e9e et Rousse')).toBe(true);
|
||||
});
|
||||
|
||||
it('same gender cannot breed (\u2642+\u2642)', () => {
|
||||
const r = calc.compute({
|
||||
'Rousse': { m: 2, f: 0 },
|
||||
'Dor\u00e9e': { m: 2, f: 0 },
|
||||
});
|
||||
expect(r.generations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('same gender cannot breed (\u2640+\u2640)', () => {
|
||||
const r = calc.compute({
|
||||
'Rousse': { m: 0, f: 2 },
|
||||
'Dor\u00e9e': { m: 0, f: 2 },
|
||||
});
|
||||
expect(r.generations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('babies cascade to next generation', () => {
|
||||
const r = calc.compute({
|
||||
'Rousse': { m: 2, f: 2 },
|
||||
'Amande': { m: 2, f: 2 },
|
||||
'Dor\u00e9e': { m: 2, f: 2 },
|
||||
});
|
||||
const gen2 = r.generations.find(g => g.gen === 2);
|
||||
expect(gen2).toBeDefined();
|
||||
expect(gen2!.crossings.length).toBeGreaterThan(0);
|
||||
// Check if gen 3 exists (cascade from gen 2 babies)
|
||||
// This depends on whether gen 2 babies can breed with each other
|
||||
});
|
||||
|
||||
it('multiple pairs produce multiple babies', () => {
|
||||
const r = calc.compute({
|
||||
'Rousse': { m: 3, f: 3 },
|
||||
'Dor\u00e9e': { m: 3, f: 3 },
|
||||
});
|
||||
const gen2 = r.generations.find(g => g.gen === 2);
|
||||
expect(gen2).toBeDefined();
|
||||
const dr = gen2!.crossings.find(c => c.name === 'Dor\u00e9e et Rousse');
|
||||
expect(dr).toBeDefined();
|
||||
// With 3\u2642+3\u2640 of each, we can make up to 3 pairs (limited by one side)
|
||||
expect(dr!.qty).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
60
tests/unit/domain/Race.test.ts
Normal file
60
tests/unit/domain/Race.test.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { RACE_GEN, GEN_COLORS, BREEDING_RECIPES, RACES_DATA, isBaseRace, generationOf, raceColor } from '@domain/value-objects/Race';
|
||||
import { Gender } from '@domain/value-objects/Gender';
|
||||
|
||||
describe('Race', () => {
|
||||
it('base races are gen 1', () => {
|
||||
expect(generationOf('Rousse')).toBe(1);
|
||||
expect(generationOf('Amande')).toBe(1);
|
||||
expect(generationOf('Dorée')).toBe(1);
|
||||
expect(isBaseRace('Rousse')).toBe(true);
|
||||
});
|
||||
|
||||
it('gen 2 races are correctly mapped', () => {
|
||||
expect(generationOf('Dorée et Rousse')).toBe(2);
|
||||
expect(generationOf('Amande et Dorée')).toBe(2);
|
||||
expect(generationOf('Amande et Rousse')).toBe(2);
|
||||
expect(isBaseRace('Dorée et Rousse')).toBe(false);
|
||||
});
|
||||
|
||||
it('GEN_COLORS has 10 entries', () => {
|
||||
expect(Object.keys(GEN_COLORS)).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('BREEDING_RECIPES maps baby to parents', () => {
|
||||
expect(BREEDING_RECIPES['Dorée et Rousse']).toEqual(['Rousse', 'Dorée']);
|
||||
expect(BREEDING_RECIPES['Amande et Dorée']).toEqual(['Amande', 'Dorée']);
|
||||
});
|
||||
|
||||
it('Ebène = Amande et Dorée + Dorée et Rousse (pas Amande et Rousse)', () => {
|
||||
expect(BREEDING_RECIPES['Ebène']).toEqual(['Amande et Dorée', 'Dorée et Rousse']);
|
||||
});
|
||||
|
||||
it('RACES_DATA has generations 2-10', () => {
|
||||
for (let g = 2; g <= 10; g++) {
|
||||
expect(RACES_DATA[g]).toBeDefined();
|
||||
expect(RACES_DATA[g]!.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('raceColor returns a color string', () => {
|
||||
expect(raceColor('Rousse')).toBe('#c8622a');
|
||||
expect(raceColor('Dorée')).toBe('#e8b820');
|
||||
expect(raceColor('Unknown')).toBe('#888');
|
||||
});
|
||||
|
||||
it('all breeding recipes have valid parents in RACE_GEN', () => {
|
||||
for (const [baby, [pA, pB]] of Object.entries(BREEDING_RECIPES)) {
|
||||
expect(RACE_GEN[baby]).toBeDefined();
|
||||
expect(RACE_GEN[pA]).toBeDefined();
|
||||
expect(RACE_GEN[pB]).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gender', () => {
|
||||
it('has male, female, neutral', () => {
|
||||
const values: Gender[] = ['m', 'f', 'n'];
|
||||
expect(values).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
49
tests/unit/domain/ReapproCalculator.test.ts
Normal file
49
tests/unit/domain/ReapproCalculator.test.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ReapproCalculator } from '@domain/services/ReapproCalculator';
|
||||
|
||||
describe('ReapproCalculator', () => {
|
||||
const calc = new ReapproCalculator();
|
||||
|
||||
it('empty target returns empty', () => {
|
||||
const r = calc.compute({ target: '', qty: 1, repro: {}, inverted: {} });
|
||||
expect(r.steps).toHaveLength(0);
|
||||
expect(r.gen1Needs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('unknown target returns empty', () => {
|
||||
const r = calc.compute({ target: 'Unknown', qty: 1, repro: {}, inverted: {} });
|
||||
expect(r.steps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('gen2 race needs 2 gen1 parents', () => {
|
||||
const r = calc.compute({ target: 'Dorée et Rousse', qty: 4, repro: {}, inverted: {} });
|
||||
expect(r.steps).toHaveLength(1);
|
||||
expect(r.steps[0]!.parentA).toBe('Rousse');
|
||||
expect(r.steps[0]!.parentB).toBe('Dorée');
|
||||
expect(r.steps[0]!.couples).toBe(4);
|
||||
expect(r.gen1Needs).toHaveLength(2);
|
||||
expect(r.totalGen1).toBe(8); // 4 Rousse + 4 Dorée
|
||||
});
|
||||
|
||||
it('gender inversion swaps parents', () => {
|
||||
const r = calc.compute({ target: 'Dorée et Rousse', qty: 4, repro: {}, inverted: { 'Dorée et Rousse': true } });
|
||||
expect(r.steps[0]!.parentA).toBe('Dorée');
|
||||
expect(r.steps[0]!.parentB).toBe('Rousse');
|
||||
});
|
||||
|
||||
it('breeders reduce couple count', () => {
|
||||
const r = calc.compute({ target: 'Dorée et Rousse', qty: 4, repro: { 'Dorée et Rousse': 2 }, inverted: {} });
|
||||
expect(r.steps[0]!.couples).toBe(2);
|
||||
});
|
||||
|
||||
it('gen1 needs include male/female split', () => {
|
||||
const r = calc.compute({ target: 'Dorée et Rousse', qty: 2, repro: {}, inverted: {} });
|
||||
const rousse = r.gen1Needs.find(n => n.name === 'Rousse');
|
||||
const doree = r.gen1Needs.find(n => n.name === 'Dorée');
|
||||
expect(rousse).toBeDefined();
|
||||
expect(doree).toBeDefined();
|
||||
// Default: parentA (Rousse) = male, parentB (Dorée) = female
|
||||
expect(rousse!.m).toBe(2);
|
||||
expect(doree!.f).toBe(2);
|
||||
});
|
||||
});
|
||||
42
tests/unit/domain/SerenityCalculator.test.ts
Normal file
42
tests/unit/domain/SerenityCalculator.test.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SerenityCalculator } from '@domain/services/SerenityCalculator';
|
||||
|
||||
describe('SerenityCalculator', () => {
|
||||
const calc = new SerenityCalculator();
|
||||
|
||||
it('null target → not done, 0 seconds', () => {
|
||||
const r = calc.computeEta({ currentSerenite: 0, target: null, activeGauges: [], gaugeLevels: {} });
|
||||
expect(r.done).toBe(false);
|
||||
expect(r.seconds).toBe(0);
|
||||
});
|
||||
|
||||
it('target already reached → done', () => {
|
||||
const r = calc.computeEta({ currentSerenite: -5000, target: -5000, activeGauges: ['baffeur'], gaugeLevels: { baffeur: 50000 } });
|
||||
expect(r.done).toBe(true);
|
||||
});
|
||||
|
||||
it('needs baffeur to decrease serenite', () => {
|
||||
const r = calc.computeEta({ currentSerenite: 0, target: -5000, activeGauges: [], gaugeLevels: {} });
|
||||
expect(r.needsGauge).toBe('baffeur');
|
||||
expect(r.seconds).toBe(Infinity);
|
||||
});
|
||||
|
||||
it('needs caresseur to increase serenite', () => {
|
||||
const r = calc.computeEta({ currentSerenite: 0, target: 40, activeGauges: [], gaugeLevels: {} });
|
||||
expect(r.needsGauge).toBe('caresseur');
|
||||
});
|
||||
|
||||
it('computes time with baffeur active', () => {
|
||||
const r = calc.computeEta({ currentSerenite: 0, target: -100, activeGauges: ['baffeur'], gaugeLevels: { baffeur: 50000 } });
|
||||
expect(r.done).toBe(false);
|
||||
expect(r.seconds).toBeGreaterThan(0);
|
||||
expect(r.seconds).toBeLessThan(Infinity);
|
||||
});
|
||||
|
||||
it('computes time with caresseur active', () => {
|
||||
const r = calc.computeEta({ currentSerenite: 0, target: 100, activeGauges: ['caresseur'], gaugeLevels: { caresseur: 50000 } });
|
||||
expect(r.done).toBe(false);
|
||||
expect(r.seconds).toBeGreaterThan(0);
|
||||
expect(r.seconds).toBeLessThan(Infinity);
|
||||
});
|
||||
});
|
||||
163
tests/unit/domain/StockSimulator.test.ts
Normal file
163
tests/unit/domain/StockSimulator.test.ts
Normal file
@ -0,0 +1,163 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { simulateStock } from '@domain/services/StockSimulator';
|
||||
|
||||
describe('simulateStock', () => {
|
||||
it('stock vide → aucun croisement', () => {
|
||||
const r = simulateStock({});
|
||||
expect(r.crossings).toHaveLength(0);
|
||||
expect(r.unusedStock).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('stock à zéro → aucun croisement', () => {
|
||||
const r = simulateStock({ Rousse: { m: 0, f: 0 } });
|
||||
expect(r.crossings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('♂Rousse + ♀Dorée → Dorée et Rousse ×1 (config1)', () => {
|
||||
const r = simulateStock({ Rousse: { m: 1, f: 0 }, Dorée: { m: 0, f: 1 } });
|
||||
expect(r.crossings).toHaveLength(1);
|
||||
expect(r.crossings[0]!.baby).toBe('Dorée et Rousse');
|
||||
expect(r.crossings[0]!.count).toBe(1);
|
||||
expect(r.crossings[0]!.pAMale).toBe(1); // ♂Rousse
|
||||
expect(r.crossings[0]!.pAFemale).toBe(0);
|
||||
expect(r.crossings[0]!.pBFemale).toBe(1); // ♀Dorée
|
||||
expect(r.crossings[0]!.pBMale).toBe(0);
|
||||
});
|
||||
|
||||
it('♀Rousse + ♂Dorée → Dorée et Rousse ×1 (config2)', () => {
|
||||
const r = simulateStock({ Rousse: { m: 0, f: 1 }, Dorée: { m: 1, f: 0 } });
|
||||
expect(r.crossings).toHaveLength(1);
|
||||
expect(r.crossings[0]!.baby).toBe('Dorée et Rousse');
|
||||
expect(r.crossings[0]!.count).toBe(1);
|
||||
expect(r.crossings[0]!.pAMale).toBe(0);
|
||||
expect(r.crossings[0]!.pAFemale).toBe(1); // ♀Rousse config2
|
||||
});
|
||||
|
||||
it('♂♂Rousse + ♀♀Dorée (même sexe d\'un côté) → 2 bébés config1 seulement', () => {
|
||||
const r = simulateStock({ Rousse: { m: 2, f: 0 }, Dorée: { m: 0, f: 2 } });
|
||||
expect(r.crossings).toHaveLength(1);
|
||||
expect(r.crossings[0]!.count).toBe(2);
|
||||
expect(r.crossings[0]!.pAMale).toBe(2); // 2× ♂Rousse × ♀Dorée
|
||||
expect(r.crossings[0]!.pAFemale).toBe(0); // pas de config2
|
||||
});
|
||||
|
||||
it('les deux configs simultanées : 2♂/2♀ × 2♂/2♀ → 4 bébés', () => {
|
||||
const r = simulateStock({ Rousse: { m: 2, f: 2 }, Dorée: { m: 2, f: 2 } });
|
||||
const c = r.crossings.find(x => x.baby === 'Dorée et Rousse');
|
||||
expect(c).toBeDefined();
|
||||
expect(c!.count).toBe(4);
|
||||
expect(c!.pAMale).toBe(2); // c1 = min(♂R=2, ♀D=2)
|
||||
expect(c!.pAFemale).toBe(2); // c2 = min(♀R=2, ♂D=2)
|
||||
});
|
||||
|
||||
it('distribution proportionnelle : 4♂/4♀ × 3 races → 4 bébés par croisement Gen2', () => {
|
||||
const r = simulateStock({
|
||||
Rousse: { m: 4, f: 4 },
|
||||
Dorée: { m: 4, f: 4 },
|
||||
Amande: { m: 4, f: 4 },
|
||||
});
|
||||
const gen2 = r.crossings.filter(c => c.gen === 2);
|
||||
expect(gen2).toHaveLength(3);
|
||||
for (const c of gen2) {
|
||||
expect(c.count).toBe(4);
|
||||
}
|
||||
});
|
||||
|
||||
it('cascade complète Gen2→Gen3→Gen4→Gen5 avec 4♂/4♀ × 3 races', () => {
|
||||
const r = simulateStock({
|
||||
Rousse: { m: 4, f: 4 },
|
||||
Dorée: { m: 4, f: 4 },
|
||||
Amande: { m: 4, f: 4 },
|
||||
});
|
||||
|
||||
// Gen2 : AmR×4, DorR×4, AmD×4 = 12
|
||||
const gen2 = r.crossings.filter(c => c.gen === 2);
|
||||
expect(gen2.reduce((s, c) => s + c.count, 0)).toBe(12);
|
||||
|
||||
// Gen3 : Ebène×2, Indigo×2 = 4
|
||||
const gen3 = r.crossings.filter(c => c.gen === 3);
|
||||
expect(gen3).toHaveLength(2);
|
||||
expect(gen3.find(c => c.baby === 'Ebène')!.count).toBe(2);
|
||||
expect(gen3.find(c => c.baby === 'Indigo')!.count).toBe(2);
|
||||
|
||||
// Gen4 : Ebène et Indigo×2
|
||||
const gen4 = r.crossings.filter(c => c.gen === 4);
|
||||
expect(gen4).toHaveLength(1);
|
||||
expect(gen4[0]!.baby).toBe('Ebène et Indigo');
|
||||
expect(gen4[0]!.count).toBe(2);
|
||||
|
||||
// Gen5 : Orchidée×2 (Pourpre ignoré car EI allocation = 0)
|
||||
const gen5 = r.crossings.filter(c => c.gen === 5);
|
||||
expect(gen5).toHaveLength(1);
|
||||
expect(gen5[0]!.baby).toBe('Orchidée');
|
||||
expect(gen5[0]!.count).toBe(2);
|
||||
|
||||
// Total : 12 + 4 + 2 + 2 = 20
|
||||
const total = r.crossings.reduce((s, c) => s + c.count, 0);
|
||||
expect(total).toBe(20);
|
||||
});
|
||||
|
||||
it('allocation proportionnelle donne la priorité au second croisement quand le premier reçoit 0', () => {
|
||||
// EI participe à Pourpre (1er) et Orchidée (2nd) en Gen5
|
||||
// Avec 1♂/1♀ EI, alloc = floor(1/2) = 0 pour Pourpre → 0 bébés
|
||||
// Orchidée : EI n'a plus qu'1 croisement restant → alloc = 1 → 2 bébés
|
||||
const r = simulateStock({
|
||||
'Ebène et Indigo': { m: 1, f: 1 },
|
||||
'Amande et Rousse': { m: 1, f: 1 },
|
||||
'Dorée et Rousse': { m: 1, f: 1 },
|
||||
});
|
||||
expect(r.crossings.find(c => c.baby === 'Pourpre')).toBeUndefined();
|
||||
expect(r.crossings.find(c => c.baby === 'Orchidée')?.count).toBe(2);
|
||||
});
|
||||
|
||||
it('race sans partenaire → stock inutilisé', () => {
|
||||
const r = simulateStock({ Rousse: { m: 5, f: 5 } });
|
||||
expect(r.crossings).toHaveLength(0);
|
||||
expect(r.unusedStock).toHaveLength(1);
|
||||
expect(r.unusedStock[0]!.race).toBe('Rousse');
|
||||
expect(r.unusedStock[0]!.m).toBe(5);
|
||||
expect(r.unusedStock[0]!.f).toBe(5);
|
||||
});
|
||||
|
||||
it('stock restant après simulation correctement identifié', () => {
|
||||
// 1♂ Rousse + 1♀ Dorée → 1 DorR. Rousse ♀ = 3 restantes
|
||||
const r = simulateStock({ Rousse: { m: 1, f: 3 }, Dorée: { m: 0, f: 1 } });
|
||||
expect(r.crossings).toHaveLength(1);
|
||||
expect(r.crossings[0]!.count).toBe(1);
|
||||
const leftR = r.unusedStock.find(u => u.race === 'Rousse');
|
||||
expect(leftR).toBeDefined();
|
||||
expect(leftR!.f).toBe(3);
|
||||
});
|
||||
|
||||
it('bébés répartis ♂/♀ pour les générations suivantes', () => {
|
||||
// 1♂/1♀ Rousse + 1♂/1♀ Dorée → 2 DorR (1♂, 1♀)
|
||||
// 1♂/1♀ Amande + 1♂/1♀ Rousse_new? Non, Amande n'a pas de stock
|
||||
const r = simulateStock({ Rousse: { m: 1, f: 1 }, Dorée: { m: 1, f: 1 } });
|
||||
expect(r.crossings).toHaveLength(1);
|
||||
expect(r.crossings[0]!.count).toBe(2);
|
||||
// Bébés DorR dans unused : 1♂ + 1♀
|
||||
const baby = r.unusedStock.find(u => u.race === 'Dorée et Rousse');
|
||||
expect(baby).toBeDefined();
|
||||
expect(baby!.m).toBe(1);
|
||||
expect(baby!.f).toBe(1);
|
||||
});
|
||||
|
||||
it('ne modifie pas l\'inventaire d\'entrée (immutabilité)', () => {
|
||||
const inv = { Rousse: { m: 2, f: 2 }, Dorée: { m: 2, f: 2 } };
|
||||
simulateStock(inv);
|
||||
expect(inv.Rousse.m).toBe(2);
|
||||
expect(inv.Rousse.f).toBe(2);
|
||||
expect(inv.Dorée.m).toBe(2);
|
||||
expect(inv.Dorée.f).toBe(2);
|
||||
});
|
||||
|
||||
it('stock asymétrique : 3♂/1♀ Rousse + 1♂/3♀ Dorée', () => {
|
||||
const r = simulateStock({ Rousse: { m: 3, f: 1 }, Dorée: { m: 1, f: 3 } });
|
||||
const c = r.crossings.find(x => x.baby === 'Dorée et Rousse');
|
||||
expect(c).toBeDefined();
|
||||
// c1 = min(♂R=3, ♀D=3) = 3, c2 = min(♀R=1, ♂D=1) = 1 → bred = 4
|
||||
expect(c!.count).toBe(4);
|
||||
expect(c!.pAMale).toBe(3);
|
||||
expect(c!.pAFemale).toBe(1);
|
||||
});
|
||||
});
|
||||
34
tests/unit/domain/Tier.test.ts
Normal file
34
tests/unit/domain/Tier.test.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { tierRate, tierNum, TIER_THRESHOLDS } from '@domain/value-objects/Tier';
|
||||
|
||||
describe('Tier', () => {
|
||||
it('tier 1: level 0-40000, rate 10', () => {
|
||||
expect(tierRate(0)).toBe(10);
|
||||
expect(tierRate(39999)).toBe(10);
|
||||
expect(tierRate(40000)).toBe(10);
|
||||
expect(tierNum(0)).toBe(1);
|
||||
expect(tierNum(40000)).toBe(1);
|
||||
});
|
||||
|
||||
it('tier 2: level 40001-70000, rate 20', () => {
|
||||
expect(tierRate(40001)).toBe(20);
|
||||
expect(tierRate(70000)).toBe(20);
|
||||
expect(tierNum(50000)).toBe(2);
|
||||
});
|
||||
|
||||
it('tier 3: level 70001-90000, rate 30', () => {
|
||||
expect(tierRate(70001)).toBe(30);
|
||||
expect(tierRate(90000)).toBe(30);
|
||||
expect(tierNum(80000)).toBe(3);
|
||||
});
|
||||
|
||||
it('tier 4: level 90001+, rate 40', () => {
|
||||
expect(tierRate(90001)).toBe(40);
|
||||
expect(tierRate(100000)).toBe(40);
|
||||
expect(tierNum(95000)).toBe(4);
|
||||
});
|
||||
|
||||
it('TIER_THRESHOLDS has 4 entries', () => {
|
||||
expect(TIER_THRESHOLDS).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
41
tests/unit/domain/XpCalculator.test.ts
Normal file
41
tests/unit/domain/XpCalculator.test.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { XpCalculator } from '@domain/services/XpCalculator';
|
||||
|
||||
describe('XpCalculator', () => {
|
||||
const calc = new XpCalculator();
|
||||
|
||||
it('null target → not done, 0 seconds', () => {
|
||||
const r = calc.computeEta({ currentLevel: 1, target: null, gaugeLevels: {}, activeGauges: [] });
|
||||
expect(r.done).toBe(false);
|
||||
expect(r.seconds).toBe(0);
|
||||
});
|
||||
|
||||
it('level already reached → done', () => {
|
||||
const r = calc.computeEta({ currentLevel: 100, target: 100, gaugeLevels: { mangeoire: 50000 }, activeGauges: ['mangeoire'] });
|
||||
expect(r.done).toBe(true);
|
||||
});
|
||||
|
||||
it('level above target → done', () => {
|
||||
const r = calc.computeEta({ currentLevel: 150, target: 100, gaugeLevels: { mangeoire: 50000 }, activeGauges: ['mangeoire'] });
|
||||
expect(r.done).toBe(true);
|
||||
});
|
||||
|
||||
it('needs mangeoire gauge', () => {
|
||||
const r = calc.computeEta({ currentLevel: 1, target: 100, gaugeLevels: {}, activeGauges: [] });
|
||||
expect(r.needsGauge).toBe('mangeoire');
|
||||
expect(r.seconds).toBe(Infinity);
|
||||
});
|
||||
|
||||
it('computes seconds to level up', () => {
|
||||
const r = calc.computeEta({ currentLevel: 1, target: 10, gaugeLevels: { mangeoire: 50000 }, activeGauges: ['mangeoire'] });
|
||||
expect(r.done).toBe(false);
|
||||
expect(r.seconds).toBeGreaterThan(0);
|
||||
expect(r.seconds).toBeLessThan(Infinity);
|
||||
});
|
||||
|
||||
it('regression: should NOT show ~23h for short XP gain', () => {
|
||||
// Bug v1.1.4: timer displayed ~23h instead of real time
|
||||
const r = calc.computeEta({ currentLevel: 1, target: 5, gaugeLevels: { mangeoire: 50000 }, activeGauges: ['mangeoire'] });
|
||||
expect(r.seconds).toBeLessThan(3600); // Must be under 1h
|
||||
});
|
||||
});
|
||||
45
tests/unit/domain/XpTable.test.ts
Normal file
45
tests/unit/domain/XpTable.test.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { XP_RAW, xpForLevel, levelFromXp } from '@domain/value-objects/XpTable';
|
||||
|
||||
describe('XpTable', () => {
|
||||
it('has 200 entries', () => {
|
||||
expect(Object.keys(XP_RAW)).toHaveLength(200);
|
||||
});
|
||||
|
||||
it('level 1 requires 0 XP', () => {
|
||||
expect(xpForLevel(1)).toBe(0);
|
||||
});
|
||||
|
||||
it('level 200 requires 867582 XP', () => {
|
||||
expect(xpForLevel(200)).toBe(867582);
|
||||
});
|
||||
|
||||
it('clamps below 1', () => {
|
||||
expect(xpForLevel(0)).toBe(0);
|
||||
expect(xpForLevel(-5)).toBe(0);
|
||||
});
|
||||
|
||||
it('clamps above 200', () => {
|
||||
expect(xpForLevel(250)).toBe(867582);
|
||||
});
|
||||
|
||||
it('levelFromXp returns correct level', () => {
|
||||
expect(levelFromXp(0)).toBe(1);
|
||||
expect(levelFromXp(18)).toBe(1);
|
||||
expect(levelFromXp(19)).toBe(2);
|
||||
expect(levelFromXp(867582)).toBe(200);
|
||||
expect(levelFromXp(999999)).toBe(200);
|
||||
});
|
||||
|
||||
it('roundtrip: xpForLevel → levelFromXp', () => {
|
||||
for (let lvl = 1; lvl <= 200; lvl++) {
|
||||
expect(levelFromXp(xpForLevel(lvl))).toBe(lvl);
|
||||
}
|
||||
});
|
||||
|
||||
it('XP values are monotonically increasing', () => {
|
||||
for (let lvl = 2; lvl <= 200; lvl++) {
|
||||
expect(XP_RAW[lvl]!).toBeGreaterThan(XP_RAW[lvl - 1]!);
|
||||
}
|
||||
});
|
||||
});
|
||||
7
tests/unit/domain/smoke.test.ts
Normal file
7
tests/unit/domain/smoke.test.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('smoke test', () => {
|
||||
it('should run', () => {
|
||||
expect(1 + 1).toBe(2);
|
||||
});
|
||||
});
|
||||
77
tests/unit/infrastructure/LocalStorageRepository.test.ts
Normal file
77
tests/unit/infrastructure/LocalStorageRepository.test.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { LocalStorageRepository } from '@infrastructure/persistence/LocalStorageRepository';
|
||||
|
||||
// Mock localStorage
|
||||
const store: Record<string, string> = {};
|
||||
const mockLocalStorage = {
|
||||
getItem: vi.fn((key: string) => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, val: string) => { store[key] = val; }),
|
||||
removeItem: vi.fn((key: string) => { delete store[key]; }),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Object.keys(store).forEach(k => delete store[k]);
|
||||
vi.stubGlobal('localStorage', mockLocalStorage);
|
||||
// No electronAPI in test
|
||||
if (typeof window !== 'undefined') {
|
||||
delete (window as any).electronAPI;
|
||||
}
|
||||
});
|
||||
|
||||
describe('LocalStorageRepository', () => {
|
||||
it('load returns null if no stored data', async () => {
|
||||
const repo = new LocalStorageRepository();
|
||||
const result = await repo.load();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('save then load round-trips', async () => {
|
||||
const repo = new LocalStorageRepository();
|
||||
const state = {
|
||||
enclos: [], activeId: null, nextEnclosId: 1,
|
||||
alarmSound: 'arpege', notifsEnabled: true, ntfyTopic: '',
|
||||
archivedStats: [], inventaire: {}, workflows: [], accouplements: [],
|
||||
};
|
||||
repo.save(state as any);
|
||||
const loaded = await repo.load();
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.alarmSound).toBe('arpege');
|
||||
expect(loaded!.accouplements).toEqual([]);
|
||||
});
|
||||
|
||||
it('save resets timer running state', async () => {
|
||||
const repo = new LocalStorageRepository();
|
||||
const state = {
|
||||
enclos: [{
|
||||
id: 1, name: 'E1', activeGauges: [], gaugeLevels: { baffeur: 0, caresseur: 0, foudroyeur: 0, abreuvoir: 0, dragofesse: 0, mangeoire: 0 },
|
||||
dragodindes: [], nextDdId: 1,
|
||||
timer: { running: true, startTime: Date.now(), pausedAt: null, pausedMs: 0, snapGauges: {}, snapStats: {} },
|
||||
alerted: {},
|
||||
}],
|
||||
activeId: 1, nextEnclosId: 2,
|
||||
alarmSound: 'arpege', notifsEnabled: true, ntfyTopic: '',
|
||||
archivedStats: [], inventaire: {}, workflows: [], accouplements: [],
|
||||
};
|
||||
repo.save(state as any);
|
||||
const loaded = await repo.load();
|
||||
expect(loaded!.enclos[0]!.timer.running).toBe(false);
|
||||
});
|
||||
|
||||
it('deserialize applies default targets migration', async () => {
|
||||
const repo = new LocalStorageRepository();
|
||||
const raw = JSON.stringify({
|
||||
enclos: [{
|
||||
id: 1, name: 'E1', activeGauges: [], gaugeLevels: { baffeur: 0, caresseur: 0, foudroyeur: 0, abreuvoir: 0, dragofesse: 0, mangeoire: 0 },
|
||||
dragodindes: [{ id: 1, name: 'DD1', stats: { serenite: 0, endurance: 0, maturite: 0, amour: 0 }, targets: {}, race: '', gender: 'n', reproducteur: 0 }],
|
||||
nextDdId: 2,
|
||||
timer: { running: false, startTime: null, pausedAt: null, pausedMs: 0, snapGauges: {}, snapStats: {} },
|
||||
alerted: {},
|
||||
}],
|
||||
activeId: 1, nextEnclosId: 2, alarmSound: 'arpege', notifsEnabled: true,
|
||||
});
|
||||
store['dd3v3'] = raw;
|
||||
const loaded = await repo.load();
|
||||
expect(loaded!.enclos[0]!.dragodindes[0]!.targets.baffeur).toBe(-5000);
|
||||
expect(loaded!.enclos[0]!.dragodindes[0]!.stats.xp).toBe(1);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user