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