Added export/import features

This commit is contained in:
2026-09-19 13:21:43 +02:00
parent f61b9025eb
commit 870457955b
14 changed files with 3363 additions and 14 deletions

278
src/lib/backup.test.js Normal file
View File

@@ -0,0 +1,278 @@
import { describe, expect, it } from 'vitest';
import {
BackupError,
backupFilename,
buildBackup,
EXPORT_FORMAT,
EXPORT_VERSION,
parseBackup,
} from './backup.js';
import { atLocalTime, MINUTE } from './time.js';
import { buildResume } from './resume.js';
import sample from './fixtures/backup.json';
const at = (date, hhmm) => atLocalTime(date, hhmm);
function fixture() {
const days = [
{
date: '2026-09-04',
status: 'closed',
plannedStartTs: at('2026-09-04', '07:30'),
plannedEndTs: at('2026-09-04', '17:30'),
startedTs: at('2026-09-04', '07:30'),
endedTs: at('2026-09-04', '17:30'),
lunchType: 'restaurant',
restaurant: 'Le Relais',
closePromptedTs: null,
},
];
const entries = [
{ id: 7, date: '2026-09-04', seq: 0, kind: 'travel', task: 'Chargement / transport', client: 'Dupont SA', startTs: at('2026-09-04', '07:30'), endTs: at('2026-09-04', '08:15') },
{ id: 8, date: '2026-09-04', seq: 1, kind: 'work', task: 'Terrassement', client: 'Dupont SA', startTs: at('2026-09-04', '08:15'), endTs: at('2026-09-04', '17:30') },
];
return { days, entries, settings: { roundingMinutes: 15, theme: 'jour' } };
}
const roundTrip = (data) => parseBackup(JSON.stringify(buildBackup(data)));
describe('buildBackup', () => {
it('stamps the format so a foreign file can be recognised', () => {
const out = buildBackup(fixture());
expect(out.format).toBe(EXPORT_FORMAT);
expect(out.version).toBe(EXPORT_VERSION);
expect(typeof out.exportedAt).toBe('string');
});
it('strips row ids, which are local to one database', () => {
expect(buildBackup(fixture()).entries.every((e) => !('id' in e))).toBe(true);
});
it('names the file by date', () => {
expect(backupFilename(at('2026-09-06', '12:00'))).toBe('timelog-2026-09-06.json');
});
});
describe('round trip', () => {
it('preserves days, entries and settings', () => {
const out = roundTrip(fixture());
expect(out.dayCount).toBe(1);
expect(out.entryCount).toBe(2);
expect(out.settings).toEqual({ roundingMinutes: 15, theme: 'jour' });
expect(out.days[0].restaurant).toBe('Le Relais');
expect(out.entries.map((e) => e.task)).toEqual([
'Chargement / transport',
'Terrassement',
]);
});
it('keeps the chain gapless', () => {
const { entries } = roundTrip(fixture());
expect(entries.every((e, i) => i === 0 || entries[i - 1].endTs === e.startTs)).toBe(true);
});
});
describe('parseBackup rejects what it should', () => {
it('refuses non-JSON', () => {
expect(() => parseBackup('not json at all')).toThrow(BackupError);
});
it('refuses a JSON file that is not ours', () => {
expect(() => parseBackup('{"hello":"world"}')).toThrow(/pas un export TimeLog/);
});
it('refuses a bare array', () => {
expect(() => parseBackup('[]')).toThrow(BackupError);
});
it('refuses a newer format version', () => {
const raw = { ...buildBackup(fixture()), version: EXPORT_VERSION + 1 };
expect(() => parseBackup(JSON.stringify(raw))).toThrow(/version plus récente/);
});
it('refuses a damaged non-empty file with no usable day', () => {
const raw = { ...buildBackup(fixture()), entries: [] };
expect(() => parseBackup(JSON.stringify(raw))).toThrow(/Aucune journée/);
});
it('reports missing tables rather than importing nothing', () => {
const raw = { format: EXPORT_FORMAT, version: 1 };
expect(() => parseBackup(JSON.stringify(raw))).toThrow(/Export incomplet/);
});
it.each([0, -1, 0.5, '1', null])('refuses unsupported version %s', (version) => {
expect(() => parseBackup(JSON.stringify({ ...buildBackup(fixture()), version }))).toThrow(BackupError);
});
it('rejects duplicate dates before any database write', () => {
const data = fixture();
data.days.push({ ...data.days[0] });
expect(() => roundTrip(data)).toThrow(/Journée en double/);
});
it('rejects impossible calendar dates and out-of-range timestamps', () => {
const data = fixture();
data.days[0].date = data.entries[0].date = data.entries[1].date = '2026-02-30';
expect(() => roundTrip(data)).toThrow(/Aucune journée/);
const huge = fixture();
huge.entries.forEach((e) => (e.startTs = 1e30));
expect(() => roundTrip(huge)).toThrow(/Aucune journée/);
});
it.each([
{ workdays: 'monday' }, { dayStart: '' }, { dayEnd: '25:00' },
{ roundingMinutes: 0 }, { theme: 'other' }, { retentionDays: -1 }, [],
])('rejects settings that would break the application: %j', (settings) => {
expect(() => roundTrip({ ...fixture(), settings })).toThrow(/Réglage/);
});
});
describe('backup fidelity', () => {
it('accepts an empty database and a settings-only backup', () => {
const settings = { theme: 'nuit', workdays: [] };
expect(roundTrip({ days: [], entries: [], settings })).toMatchObject({
dayCount: 0, entryCount: 0, settings,
});
});
it('preserves every worked total in the provided 20-day fixture', () => {
const out = parseBackup(JSON.stringify(sample));
expect(out.dayCount).toBe(sample.days.length);
expect(out.entryCount).toBe(sample.entries.length);
expect(out.dropped).toBe(0);
for (const day of sample.days) {
const original = buildResume(day, sample.entries.filter((e) => e.date === day.date));
const restored = buildResume(out.days.find((d) => d.date === day.date), out.entries.filter((e) => e.date === day.date));
expect(restored).toEqual(original);
}
});
it('preserves a gap after a day was stopped and resumed', () => {
const data = fixture();
data.entries[1].startTs += 30 * MINUTE;
const out = roundTrip(data);
expect(out.entries[1].startTs - out.entries[0].endTs).toBe(30 * MINUTE);
expect(buildResume(out.days[0], out.entries)).toEqual(buildResume(data.days[0], data.entries));
});
it('closes a running entry at export time, preserving its elapsed work', () => {
const data = fixture();
data.days[0].status = 'active';
data.days[0].endedTs = null;
data.entries[1].endTs = null;
const now = at('2026-09-04', '10:00');
const out = parseBackup(JSON.stringify(buildBackup(data, now)));
expect(out.entries[1].endTs).toBe(now);
expect(out.days[0].endedTs).toBe(now);
expect(out.closedActive).toBe(1);
expect(buildResume(out.days[0], out.entries).workedMs).toBe(150 * MINUTE);
});
it('requires a usable export timestamp to finish an open entry', () => {
const data = fixture();
data.days[0].status = 'active';
data.entries[1].endTs = null;
const raw = { ...buildBackup(data), exportedAt: 'broken' };
expect(() => parseBackup(JSON.stringify(raw))).toThrow(/Date d’export/);
});
it('ignores an invalid optional export date without breaking the preview', () => {
const raw = { ...buildBackup(fixture()), exportedAt: 'broken' };
expect(parseBackup(JSON.stringify(raw)).exportedAt).toBeNull();
});
it('counts orphan entries and malformed records instead of silently dropping them', () => {
const data = buildBackup(fixture());
data.entries.push(null, { ...data.entries[0], date: '2026-09-05' });
expect(parseBackup(JSON.stringify(data)).dropped).toBe(2);
});
it('accepts a UTF-8 BOM and filters unknown settings keys', () => {
const raw = buildBackup(fixture());
raw.settings.loaded = true;
raw.settings.workdays = [5, 1, 1];
const out = parseBackup('\uFEFF' + JSON.stringify(raw));
expect(out.settings).toEqual({ roundingMinutes: 15, theme: 'jour', workdays: [1, 5] });
});
});
describe('parseBackup repairs its own files', () => {
const withDays = (days, entries) =>
JSON.stringify({ format: EXPORT_FORMAT, version: 1, days, entries });
it('forces an imported day closed, so there is never a second active day', () => {
const data = fixture();
data.days[0].status = 'active';
data.days[0].endedTs = null;
data.entries[1].endTs = null;
const out = roundTrip(data);
expect(out.days[0].status).toBe('closed');
expect(out.days[0].endedTs).toBe(out.entries[1].endTs);
});
it('renumbers seq densely and orders by start time', () => {
const data = fixture();
data.entries[0].seq = 5;
data.entries[1].seq = 99;
const out = roundTrip(data);
expect(out.entries.map((e) => e.seq)).toEqual([0, 1]);
expect(out.entries[0].task).toBe('Chargement / transport');
});
it('drops an entry with no usable start time and counts it', () => {
const data = fixture();
data.entries.push({ date: '2026-09-04', seq: 2, task: 'Cassé', startTs: 'nope' });
const out = roundTrip(data);
expect(out.dropped).toBe(1);
expect(out.entryCount).toBe(2);
});
it('drops a day that carries no entries', () => {
const raw = withDays(
[
{ date: '2026-09-04', status: 'closed' },
{ date: '2026-09-05', status: 'closed' },
],
[{ date: '2026-09-04', seq: 0, kind: 'work', task: 'A', startTs: at('2026-09-04', '08:00'), endTs: at('2026-09-04', '09:00') }],
);
const out = parseBackup(raw);
expect(out.dayCount).toBe(1);
expect(out.dropped).toBe(1);
});
it('derives day bounds when the file omits them', () => {
const raw = withDays(
[{ date: '2026-09-04', status: 'closed' }],
[
{ date: '2026-09-04', seq: 0, kind: 'work', task: 'A', startTs: at('2026-09-04', '08:00'), endTs: at('2026-09-04', '09:00') },
{ date: '2026-09-04', seq: 1, kind: 'work', task: 'B', startTs: at('2026-09-04', '09:00'), endTs: at('2026-09-04', '11:30') },
],
);
const out = parseBackup(raw);
expect(out.days[0].startedTs).toBe(at('2026-09-04', '08:00'));
expect(out.days[0].endedTs).toBe(at('2026-09-04', '11:30'));
});
it('substitutes a placeholder for a nameless task', () => {
const raw = withDays(
[{ date: '2026-09-04', status: 'closed' }],
[{ date: '2026-09-04', seq: 0, startTs: at('2026-09-04', '08:00'), endTs: at('2026-09-04', '09:00') }],
);
expect(parseBackup(raw).entries[0].task).toBe('Poste');
});
it('discards a restaurant name on a panier day', () => {
const data = fixture();
data.days[0].lunchType = 'panier';
expect(roundTrip(data).days[0].restaurant).toBeNull();
});
it('repairs an end time that precedes its own start', () => {
const raw = withDays(
[{ date: '2026-09-04', status: 'closed' }],
[{ date: '2026-09-04', seq: 0, kind: 'work', task: 'A', startTs: at('2026-09-04', '10:00'), endTs: at('2026-09-04', '08:00') }],
);
const out = parseBackup(raw);
expect(out.entries[0].endTs).toBe(out.entries[0].startTs);
});
});