136 lines
8.6 KiB
JavaScript
136 lines
8.6 KiB
JavaScript
import 'fake-indexeddb/auto';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { db, getDay, getEntries, reopenDay, closeDay } from './db.js';
|
|
import { buildResume, resumeToText } from './resume.js';
|
|
import { buildStats } from './stats.js';
|
|
import { buildBackup, parseBackup } from './backup.js';
|
|
import { buildModel } from './predict.js';
|
|
import { automateDay } from './automation.js';
|
|
import { insertGapEntry, insertRecordedEntry, removeRecordedEntry, saveRecordedEntry } from './editing.js';
|
|
import { DEFAULT_SETTINGS } from './settings.svelte.js';
|
|
import { atLocalTime, HOUR, MINUTE } from './time.js';
|
|
const date = '2026-10-08';
|
|
const at = time => atLocalTime(date, time);
|
|
const day = { date, status: 'closed', startedTs: at('08:00'), endedTs: at('17:00'), plannedStartTs: at('08:00'), plannedEndTs: at('17:00'), lunchType: 'panier' };
|
|
const slot = (seq, kind, start, end) => ({ date, seq, kind, task: kind === 'break' ? 'Pause' : kind === 'lunch' ? 'Repas' : 'Chantier', client: kind === 'work' ? 'Client A' : null, startTs: at(start), endTs: end ? at(end) : null });
|
|
beforeEach(async () => { await db.days.clear(); await db.entries.clear(); await db.settings.clear(); });
|
|
async function install(entries, record = day) { await db.days.add(record); await db.entries.bulkAdd(entries); }
|
|
|
|
describe('pauses and confirmed reopening', () => {
|
|
it('counts 8h30 work and 30 minutes break, with no recap or suggestion pollution', () => {
|
|
const entries = [slot(0, 'work', '08:00','12:00'), slot(1,'break','12:00','12:30'),slot(2,'work','12:30','17:00')];
|
|
const resume = buildResume(day, entries);
|
|
expect(resume.workedMs).toBe(8.5 * HOUR);
|
|
expect(resumeToText(resume)).not.toContain('Pause');
|
|
const stats = buildStats([day], entries);
|
|
expect(stats.breakMs).toBe(30 * MINUTE); expect(stats.lunchMs).toBe(0);
|
|
expect(stats.tasks.map(t => t.name)).toEqual(['Chantier']);
|
|
expect(buildModel(entries,[day]).taskCount.has('Pause')).toBe(false);
|
|
});
|
|
it('continues work from the recorded end', async () => {
|
|
await install([slot(0,'work','08:00','17:00')]);
|
|
expect(await reopenDay(date, at('18:00'), 'continue')).toBe(true);
|
|
expect((await getEntries(date))).toHaveLength(1);
|
|
expect(buildResume(await getDay(date),await getEntries(date), 15).workedMs).toBeGreaterThanOrEqual(9 * HOUR);
|
|
await closeDay(date, at('18:00'));
|
|
expect(buildResume(await getDay(date),await getEntries(date)).workedMs).toBe(10 * HOUR);
|
|
});
|
|
it('commits one discontinuous reopening under concurrent calls', async () => {
|
|
await install([slot(0,'work','08:00','17:00')]);
|
|
expect(await Promise.all([reopenDay(date,at('18:00'),'break'),reopenDay(date,at('18:00'),'break')])).toEqual([true,false]);
|
|
const rows = await getEntries(date);
|
|
expect(rows.map(e => e.kind)).toEqual(['work','break','work']);
|
|
expect(rows[0].endTs).toBe(at('17:00')); expect(rows[1]).toMatchObject({startTs:at('17:00'),endTs:at('18:00')});
|
|
expect(rows[2]).toMatchObject({startTs:at('18:00'),endTs:null,task:'Chantier',client:'Client A'});
|
|
});
|
|
for (const kind of ['lunch','break']) for (const mode of ['continue','break']) {
|
|
it(`preserves a last ${kind} on ${mode}`, async () => {
|
|
await install([slot(0,'work','08:00','16:00'),slot(1,kind,'16:00','17:00')]);
|
|
await reopenDay(date,at('18:00'),mode);
|
|
const rows = await getEntries(date);
|
|
expect(rows[1].kind).toBe(kind);
|
|
expect(rows[1].endTs).toBe(at(mode === 'continue' ? '18:00' : '17:00'));
|
|
expect(rows.at(-1)).toMatchObject({kind:'work',startTs:at('18:00'),task:'Chantier'});
|
|
});
|
|
}
|
|
it('creates no zero-duration break and rejects past days and invalid choices', async () => {
|
|
await install([slot(0,'work','08:00','17:00')]);
|
|
expect(await reopenDay(date,atLocalTime('2026-10-09','18:00'),'break')).toBe(false);
|
|
await expect(reopenDay(date,at('18:00'),'invalid')).rejects.toThrow();
|
|
expect((await getDay(date)).status).toBe('closed');
|
|
await reopenDay(date,at('17:00'),'break');
|
|
expect((await getEntries(date)).map(e=>e.kind)).toEqual(['work','work']);
|
|
});
|
|
it('rolls back the day and slots when writing the resumed work fails', async () => {
|
|
await install([slot(0,'work','08:00','17:00')]);
|
|
const real = db.entries.add.bind(db.entries);
|
|
const spy = vi.spyOn(db.entries,'add').mockImplementation(record => record.kind === 'work' ? Promise.reject(new Error('write failure')) : real(record));
|
|
try { await expect(reopenDay(date,at('18:00'),'break')).rejects.toThrow('write failure'); }
|
|
finally { spy.mockRestore(); }
|
|
expect((await getDay(date)).status).toBe('closed'); expect(await getEntries(date)).toHaveLength(1);
|
|
});
|
|
it('does not replace a pause at automatic lunch or closing', async () => {
|
|
await install([slot(0,'work','08:00','11:30'),slot(1,'break','11:30',null)], {...day,status:'active',endedTs:null});
|
|
await automateDay(date, {...DEFAULT_SETTINGS,autoLunch:true}, at('13:30'));
|
|
expect((await getEntries(date)).at(-1).kind).toBe('break');
|
|
await automateDay(date,DEFAULT_SETTINGS,at('17:00'),{end:true});
|
|
expect((await getEntries(date)).at(-1)).toMatchObject({kind:'break',endTs:at('17:00')});
|
|
});
|
|
it('inserts, corrects and deletes a closed-day pause with recalculated totals', async () => {
|
|
await install([slot(0,'work','08:00','17:00')]);
|
|
const id = await insertRecordedEntry((await getEntries(date))[0], {...slot(0,'break','12:00','12:30')}, at('18:00'));
|
|
let entry = await db.entries.get(id);
|
|
await saveRecordedEntry(entry, {...entry,endTs:at('13:00')}, at('18:00'));
|
|
expect(buildStats([await getDay(date)],await getEntries(date)).breakMs).toBe(HOUR);
|
|
await removeRecordedEntry(await db.entries.get(id));
|
|
expect(buildResume(await getDay(date),await getEntries(date)).workedMs).toBe(9*HOUR);
|
|
expect((await getDay(date)).status).toBe('closed');
|
|
});
|
|
it('round-trips breaks and imported gaps, and imports v1', () => {
|
|
const entries = [slot(0,'work','08:00','11:00'),slot(1,'break','12:00','12:30'),slot(2,'work','13:00','17:00')];
|
|
const payload = buildBackup({days:[day],entries,settings:null});
|
|
expect(payload.version).toBe(2);
|
|
expect(parseBackup(JSON.stringify(payload)).entries).toEqual(entries);
|
|
expect(parseBackup(JSON.stringify({...payload,version:1,entries:[slot(0,'work','08:00','17:00')]})).dayCount).toBe(1);
|
|
expect(() => parseBackup(JSON.stringify({...payload,version:3}))).toThrow(/plus récente/);
|
|
});
|
|
});
|
|
|
|
describe('preserving imported gaps while editing', () => {
|
|
it('does not fill a gap when correcting a pause', async () => {
|
|
await install([slot(0,'work','08:00','11:00'),slot(1,'break','12:00','12:30'),slot(2,'work','13:00','17:00')]);
|
|
const original = (await getEntries(date))[1];
|
|
await saveRecordedEntry(original,{...original,startTs:at('11:30'),endTs:at('12:45')},at('18:00'));
|
|
const rows = await getEntries(date);
|
|
expect(rows[0].endTs).toBe(at('11:00')); expect(rows[2].startTs).toBe(at('13:00'));
|
|
expect(buildStats([day],rows).quality.uncoveredMs).toBe(45*MINUTE);
|
|
await expect(saveRecordedEntry(rows[1],{...rows[1],startTs:at('10:00')},at('18:00'))).rejects.toThrow(/voisin/);
|
|
});
|
|
it('does not fill surrounding gaps when deleting a pause', async () => {
|
|
await install([slot(0,'work','08:00','11:00'),slot(1,'break','12:00','12:30'),slot(2,'work','13:00','17:00')]);
|
|
await removeRecordedEntry((await getEntries(date))[1]);
|
|
const rows = await getEntries(date);
|
|
expect(rows[0].endTs).toBe(at('11:00')); expect(rows[1].startTs).toBe(at('13:00'));
|
|
});
|
|
});
|
|
|
|
it('refuses to resume a closure that was corrected after confirmation opened', async () => {
|
|
await install([slot(0,'work','08:00','17:00')]);
|
|
const original = (await getEntries(date))[0];
|
|
await saveRecordedEntry(original,{...original,endTs:at('16:30')},at('18:00'));
|
|
await expect(reopenDay(date,at('18:00'),'break',{date,endedTs:at('17:00'),last:original})).rejects.toThrow(/a changé/);
|
|
expect((await getDay(date)).status).toBe('closed');
|
|
expect(await getEntries(date)).toHaveLength(1);
|
|
});
|
|
|
|
it('records a pause within a gap without changing either work neighbour', async () => {
|
|
await install([slot(0,'work','08:00','11:00'),slot(1,'work','13:00','17:00')]);
|
|
const [previous, following] = await getEntries(date);
|
|
await insertGapEntry(previous, following, slot(0,'break','11:30','12:30'), at('18:00'));
|
|
const rows = await getEntries(date);
|
|
expect(rows.map(row => row.kind)).toEqual(['work','break','work']);
|
|
expect(rows[0].endTs).toBe(at('11:00')); expect(rows[2].startTs).toBe(at('13:00'));
|
|
expect(buildStats([day],rows).quality.uncoveredMs).toBe(HOUR);
|
|
await expect(insertGapEntry(previous,following,slot(0,'break','11:30','12:30'),at('18:00'))).rejects.toThrow(/a changé/);
|
|
});
|