dd-timer/tests/unit/application/commands.test.ts
POL Mickaël 3e485fd09b chore: normalise fins de ligne CRLF → LF dans tout le repo
Applique .gitattributes sur tous les fichiers existants.
Élimine les différences fantômes entre WSL et Windows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 08:55:10 +02:00

823 lines
32 KiB
TypeScript
Executable File

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();
});
});