- 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>
823 lines
32 KiB
TypeScript
823 lines
32 KiB
TypeScript
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();
|
|
});
|
|
});
|