279 lines
10 KiB
JavaScript
279 lines
10 KiB
JavaScript
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('pardis-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 Pardis/);
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|