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