236 lines
14 KiB
JavaScript
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 });
|
|
});
|
|
});
|