Files
pardis/src/lib/automation.test.js
2026-09-20 14:38:17 +02:00

236 lines
14 KiB
JavaScript

import 'fake-indexeddb/auto';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { automateDay } from './automation.js';
import { AUTOMATION_DEFAULTS } from './automation-options.js';
import { parseBackup, parseSettings } from './backup.js';
import { db, exportBackup, getDay, getEntries, importBackup, readSettings, writeSettings } from './db.js';
import { advance, dayState, editEntry, endDay, keepWorking, predictNext, reconcile, refresh, reopenToday, setLunch, startDayNow, startLunch } from './day.svelte.js';
import { removeRecordedEntry, saveRecordedEntry } from './editing.js';
import { DEFAULT_SETTINGS, loadSettings, settings } from './settings.svelte.js';
import { buildResume } from './resume.js';
import { atLocalTime, HOUR, MINUTE } from './time.js';
const DATE = '2026-09-07', PREV = '2026-09-04';
const at = (time, date = DATE) => atLocalTime(date, time);
const allOn = () => Object.assign(settings, Object.fromEntries(Object.keys(AUTOMATION_DEFAULTS).map((key) => [key, true])));
const tick = async (time, date = DATE) => { vi.setSystemTime(at(time, date)); await reconcile(at(time, date)); };
const rows = () => getEntries(DATE);
const snapshot = async () => ({ days: await db.days.toArray(), entries: await db.entries.toArray() });
beforeEach(async () => {
vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(at('07:00'));
vi.stubGlobal('document', { documentElement: { dataset: {} } });
await db.days.clear(); await db.entries.clear(); await db.settings.clear();
Object.assign(settings, DEFAULT_SETTINGS, { retentionDays: 0 }); dayState.importing = false;
await db.days.put({ date: PREV, status: 'closed', startedTs: at('08:00', PREV), endedTs: at('17:00', PREV), plannedStartTs: at('08:00', PREV), plannedEndTs: at('17:00', PREV), lunchType: 'restaurant', restaurant: 'Le Relais', closePromptedTs: null });
await db.entries.bulkAdd([
{ date: PREV, seq: 0, kind: 'work', task: 'Pose', client: 'Client A', startTs: at('08:00', PREV), endTs: at('16:30', PREV) },
{ date: PREV, seq: 1, kind: 'travel', task: 'Retour', client: 'Client A', startTs: at('16:30', PREV), endTs: at('17:00', PREV) },
]);
await refresh();
});
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.useRealTimers(); });
describe('hands-free day', () => {
it('catches up a whole day at its planned times, including the inferred arrival', async () => {
allOn(); await tick('18:00');
const day = await getDay(DATE), list = await rows();
expect(day).toMatchObject({ status: 'closed', startedTs: at('07:30'), endedTs: at('17:30'), lunchType: 'restaurant', restaurant: 'Le Relais' });
expect(list.map(e => [e.kind, e.startTs, e.endTs])).toEqual([
['travel', at('07:30'), at('07:45')], ['work', at('07:45'), at('12:00')],
['lunch', at('12:00'), at('13:00')], ['work', at('13:00'), at('17:30')],
]);
expect(list.filter(e => e.kind === 'work').every(e => e.task === 'Pose' && e.client === 'Client A')).toBe(true);
expect(list.every(e => e.automatic)).toBe(true);
expect(buildResume(day, list).workedMs).toBe(9 * HOUR);
const before = await snapshot(); await tick('19:00'); expect(await snapshot()).toEqual(before);
});
it('runs each boundary once across frequent and concurrent reconciliations', async () => {
allOn(); await tick('11:59'); expect(await rows()).toHaveLength(1);
await Promise.all([reconcile(at('12:00')), reconcile(at('12:00'))]);
expect((await rows()).at(-1).kind).toBe('lunch'); expect(await rows()).toHaveLength(3);
const before = await rows(); await tick('12:30'); expect(await rows()).toEqual(before);
await tick('13:00'); expect((await rows()).at(-1).task).toBe('Pose');
await tick('17:30'); expect((await getDay(DATE)).status).toBe('closed');
});
it.each([1, 5, 15, 30])('uses the %i-minute precision as the inferred travel duration', async (roundingMinutes) => {
allOn(); settings.roundingMinutes = roundingMinutes; await tick('12:00');
const first = (await rows())[0]; expect(first.endTs - first.startTs).toBe(roundingMinutes * MINUTE);
});
it('settles a stale started day before opening today, without inventing absent days', async () => {
allOn(); await tick('08:00'); await tick('07:00', '2026-09-09');
expect((await getDay(DATE)).endedTs).toBe(at('17:30')); expect(await rows()).toHaveLength(4);
expect(await getDay('2026-09-08')).toBeUndefined(); expect(await getDay('2026-09-09')).toBeUndefined();
});
it('uses a named fallback when there is no previous work', async () => {
await db.days.clear(); await db.entries.clear(); allOn(); await tick('18:00');
expect((await rows()).filter(e => e.kind === 'work').map(e => e.task)).toEqual(['Travaux sur site', 'Travaux sur site']);
});
});
describe('independent options', () => {
it('preserves the old behavior with new options disabled', async () => {
await tick('18:00'); expect(await rows()).toHaveLength(1);
expect((await getDay(DATE)).status).toBe('active'); expect(dayState.needsClosePrompt).toBe(true);
});
it('can disable automatic opening while keeping manual opening available', async () => {
allOn(); settings.autoStartDay = false; await tick('10:00'); expect(await getDay(DATE)).toBeUndefined();
await startDayNow(at('10:00')); expect((await getDay(DATE)).startedTs).toBe(at('10:00'));
});
it('leaves an hour for a manually started day after the usual end', async () => {
allOn(); await startDayNow(at('18:00')); await tick('18:30');
expect((await getDay(DATE))).toMatchObject({ status: 'active', plannedEndTs: at('19:00') });
});
it('proposes the last work task, ignoring the final return trip and weekends', async () => {
settings.autoPreviousTask = true; await tick('08:00'); expect(predictNext().task).toBe('Pose');
await advance({ task: predictNext().task, client: predictNext().client }, at('08:00'));
expect((await rows()).at(-1).task).toBe('Pose');
});
it('does not infer arrival when lunch automation is off', async () => {
settings.autoArrival = true; await tick('12:00'); expect(await rows()).toHaveLength(1);
});
it('does not shorten transport unless arrival inference or half-day completion is enabled', async () => {
settings.autoLunch = true; await tick('13:00');
expect((await rows()).map(e => e.kind)).toEqual(['travel', 'lunch', 'work']);
expect((await rows())[0].endTs).toBe(at('12:00'));
});
it('fills an untouched half-day at a manual lunch or manual close', async () => {
settings.autoFillHalfDay = true; await tick('08:00'); await startLunch(at('12:00'));
expect((await rows())[0]).toMatchObject({ kind: 'work', task: 'Pose', startTs: at('07:30'), endTs: at('12:00') });
await advance({ task: 'Finitions', client: 'B' }, at('13:00')); await endDay(at('17:00'));
expect((await rows()).at(-1)).toMatchObject({ task: 'Finitions', client: 'B' });
});
it('fills a full untouched day when no lunch was recorded', async () => {
settings.autoFillHalfDay = true; await tick('08:00'); await endDay(at('17:00'));
expect(await rows()).toHaveLength(1); expect((await rows())[0].task).toBe('Pose');
});
it('keeps an automatically started pause open while the option is disabled', async () => {
allOn(); await tick('12:00'); settings.autoLunch = false; await tick('13:15');
expect((await rows()).at(-1).kind).toBe('lunch');
settings.autoLunch = true; await tick('13:15'); expect((await rows()).at(-1).startTs).toBe(at('13:00'));
});
it('keeps the original scheduled end if lunch duration changes mid-pause', async () => {
allOn(); await tick('12:00'); settings.lunchMinutes = 5; await tick('12:30');
expect((await rows()).at(-1).kind).toBe('lunch'); await tick('13:00'); expect((await rows()).at(-1).kind).toBe('work');
});
});
describe('manual priority and edge cases', () => {
it('preserves actual arrival, task, client and restaurant corrections', async () => {
allOn(); await tick('08:00'); await advance({ task: 'Urgence', client: 'B' }, at('09:00'));
await setLunch({ lunchType: 'restaurant', restaurant: 'Choix du jour' }); await tick('18:00');
const list = await rows(); expect(list[0].endTs).toBe(at('09:00'));
expect(list[1]).toMatchObject({ task: 'Urgence', client: 'B' });
expect(list.at(-1)).toMatchObject({ task: 'Urgence', client: 'B' });
expect((await getDay(DATE)).restaurant).toBe('Choix du jour');
});
it('does not shorten a manually edited transport placeholder', async () => {
allOn(); await tick('08:00'); await editEntry((await rows())[0].id, { client: 'Autre client' }); await tick('12:00');
expect((await rows())[0]).toMatchObject({ kind: 'travel', client: 'Autre client', endTs: at('12:00') });
});
it('keeps a manually started lunch manual, without a second automatic pause', async () => {
allOn(); await tick('08:00'); await startLunch(at('11:45')); await tick('13:30');
expect((await rows()).at(-1)).toMatchObject({ kind: 'lunch', startTs: at('11:45'), endTs: null });
await advance({ task: 'Autre', client: 'B' }, at('13:30')); await tick('14:00');
expect((await rows()).filter(e => e.kind === 'lunch')).toHaveLength(1);
});
it('stops automatic resumption after editing the automatic lunch', async () => {
allOn(); await tick('12:15'); const lunch = (await rows()).at(-1);
await saveRecordedEntry(lunch, { ...lunch, lunchType: 'panier' }, at('12:15')); await tick('13:15');
expect((await rows()).at(-1)).toMatchObject({ kind: 'lunch', automatic: false, endTs: null });
});
it('does not recreate a deleted lunch after changing the scheduled hour', async () => {
allOn(); await tick('13:15'); await removeRecordedEntry((await rows()).find(e => e.kind === 'lunch'));
settings.lunchStart = '14:00'; await tick('14:00'); expect((await rows()).some(e => e.kind === 'lunch')).toBe(false);
});
it('does not backdate lunch across a later manual action when enabling late', async () => {
await tick('08:00'); await advance({ task: 'Après-midi', client: 'B' }, at('14:00'));
const before = await rows(); allOn(); await tick('15:00'); expect(await rows()).toEqual(before);
});
it('never rewrites legacy entries without provenance', async () => {
await tick('08:00'); const first = (await rows())[0]; await db.entries.put({ ...first, automatic: undefined });
allOn(); await tick('12:00'); expect((await rows())[0].kind).toBe('travel'); expect((await rows())[0].endTs).toBe(at('12:00'));
});
it('skips zero-duration lunches and lunches outside the day', async () => {
allOn(); settings.lunchMinutes = 0; await tick('18:00'); expect((await rows()).some(e => e.kind === 'lunch')).toBe(false);
});
it('skips a lunch before a late start', async () => {
allOn(); await startDayNow(at('14:00')); await tick('18:00'); expect((await rows()).some(e => e.kind === 'lunch')).toBe(false);
});
it('does not create negative work when the morning is shorter than the travel estimate', async () => {
allOn(); await startDayNow(at('11:55')); await tick('12:00');
expect((await rows()).map(e => e.kind)).toEqual(['travel', 'lunch']);
expect((await rows())[0].endTs - (await rows())[0].startTs).toBe(5 * MINUTE);
});
it('closes a lunch at an early manual end without inventing afternoon work', async () => {
allOn(); await tick('08:00'); await endDay(at('12:30'));
expect((await rows()).at(-1)).toMatchObject({ kind: 'lunch', endTs: at('12:30') });
});
it('respects extensions and a manual reopening', async () => {
allOn(); await tick('16:00'); await keepWorking(at('17:30')); await tick('18:00');
expect((await getDay(DATE)).status).toBe('active'); await tick('18:30');
await reopenToday(at('18:45')); await tick('19:00'); expect((await getDay(DATE)).status).toBe('active');
expect((await rows()).filter(e => e.kind === 'lunch')).toHaveLength(1);
});
});
describe('atomicity and persistence', () => {
it('honors an option disabled in another tab even with stale in-memory settings', async () => {
allOn(); await tick('08:00'); await writeSettings({ autoLunch: false, autoEndDay: false });
await automateDay(DATE, settings, at('18:00'));
expect(await rows()).toHaveLength(1); expect((await getDay(DATE)).status).toBe('active');
});
it('honors opening disabled in persisted settings before reconciling', async () => {
allOn(); await writeSettings({ autoStartDay: false }); await tick('10:00');
expect(await getDay(DATE)).toBeUndefined();
});
it('serializes independent clock callers without duplicate inferred entries', async () => {
allOn(); await tick('08:00'); await Promise.all([automateDay(DATE, settings, at('18:00')), automateDay(DATE, settings, at('18:00'))]);
expect(await rows()).toHaveLength(4); expect((await getDay(DATE)).status).toBe('closed');
});
it('rolls back travel inference and lunch together when metadata fails', async () => {
allOn(); await tick('08:00'); const before = await snapshot();
vi.spyOn(db.days, 'update').mockRejectedValueOnce(new Error('disk'));
await expect(automateDay(DATE, settings, at('12:00'))).rejects.toThrow('disk');
expect(await snapshot()).toEqual(before);
});
it('round-trips switches, provenance and handled lunch flags in backups', async () => {
allOn(); await tick('18:00'); const config = Object.fromEntries(Object.keys(AUTOMATION_DEFAULTS).map(k => [k, settings[k]]));
await writeSettings(config); const backup = parseBackup(JSON.stringify(await exportBackup()));
expect(backup.settings).toEqual(config); expect(backup.entries.filter(e => e.date === DATE).every(e => e.automatic)).toBe(true);
expect(backup.days.find(d => d.date === DATE).autoLunchHandled).toBe(true);
await importBackup(backup, 'replace'); await loadSettings(); expect(await readSettings()).toEqual(config);
});
it.each(Object.keys(AUTOMATION_DEFAULTS))('rejects non-boolean %s settings', (key) => {
expect(() => parseSettings({ [key]: 'true' })).toThrow(); expect(parseSettings({ [key]: false })).toEqual({ [key]: false });
});
});