Initial commit

This commit is contained in:
Aaro Koivukangas
2026-10-11 03:26:15 +03:00
commit b6af96cf6d
49 changed files with 9627 additions and 0 deletions
+304
View File
@@ -0,0 +1,304 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Data } from '../../shared/types';
import { App } from '../../frontend/App';
import { installFakeApi } from './fakeApi';
// Thursday of ISO week 41; the tracked week runs Mon 2026-10-05 – Sun 2026-10-11.
const NOW = new Date('2026-10-08T12:00:00');
const data = (): Data => ({
settings: {
fullTimeWeekMinutes: 2175,
workdays: [1, 2, 3, 4, 5],
trackingStart: '2026-10-05',
openingBalanceMinutes: 0,
},
workloadPeriods: [{ from: '2026-01-01', percent: 100 }],
entries: {
'2026-10-05': { mode: 'total', minutes: 435 },
'2026-10-06': { mode: 'total', minutes: 435 },
},
});
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'], now: NOW });
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.unstubAllGlobals();
});
async function logIn(user = userEvent.setup(), password = 'secret password', username = 'aaro') {
await user.type(await screen.findByLabelText('Username'), username);
await user.type(screen.getByLabelText('Password'), password);
await user.click(screen.getByRole('button', { name: 'Log in' }));
}
const balanceCard = () => document.querySelector('section.balance') as HTMLElement;
const weekFooter = () => document.querySelector('.week-total') as HTMLElement;
describe('login', () => {
it('shows the login form when there is no session', async () => {
const { fetchMock } = installFakeApi({ data: data() });
render(<App />);
expect(await screen.findByRole('button', { name: 'Log in' })).toBeInTheDocument();
expect(fetchMock).not.toHaveBeenCalledWith('/api/data', expect.anything());
});
it('shows an error for a wrong password and stays on the form', async () => {
installFakeApi({ data: data() });
render(<App />);
await logIn(undefined, 'wrong');
expect(await screen.findByRole('alert')).toHaveTextContent('Wrong username or password');
expect(screen.getByLabelText('Password')).toHaveValue('');
});
it('loads the tracker after logging in', async () => {
installFakeApi({ data: data() });
render(<App />);
await logIn();
expect(await screen.findByRole('heading', { name: /Week 41/ })).toBeInTheDocument();
expect(screen.getByText('aaro')).toBeInTheDocument();
});
it('skips the form with an existing session, and logs out', async () => {
const { state } = installFakeApi({ data: data(), loggedIn: true });
const user = userEvent.setup();
render(<App />);
await screen.findByRole('heading', { name: /Week 41/ });
await user.click(screen.getByRole('button', { name: 'Log out' }));
expect(await screen.findByRole('button', { name: 'Log in' })).toBeInTheDocument();
expect(state.session).toBeNull();
});
});
describe('tracking hours', () => {
it('shows the week totals and the running balance up to today', async () => {
installFakeApi({ data: data(), loggedIn: true });
render(<App />);
await screen.findByRole('heading', { name: /Week 41/ });
// Two 7:15 days logged; full-week target 36:15.
expect(within(weekFooter()).getByText('14 h 30 min')).toBeInTheDocument();
expect(within(weekFooter()).getByText('36 h 15 min')).toBeInTheDocument();
// Balance counts Mon–Thu (today): Wed and Thu missing -> −14 h 30 min.
expect(within(balanceCard()).getByText('−14 h 30 min')).toBeInTheDocument();
expect(within(balanceCard()).getByText('hours short')).toBeInTheDocument();
});
it('updates totals as you type and saves to the server', async () => {
const { state } = installFakeApi({ data: data(), loggedIn: true });
const user = userEvent.setup();
render(<App />);
await screen.findByRole('heading', { name: /Week 41/ });
await user.type(screen.getByLabelText('Hours worked on 2026-10-07'), '8:15');
await user.type(screen.getByLabelText('Hours worked on 2026-10-08'), '7h15');
expect(within(balanceCard()).getByText('+1 h 00 min')).toBeInTheDocument();
expect(within(balanceCard()).getByText('extra hours banked')).toBeInTheDocument();
await waitFor(() => expect(state.data.entries['2026-10-08']).toEqual({ mode: 'total', minutes: 435 }));
expect(state.data.entries['2026-10-07']).toEqual({ mode: 'total', minutes: 495 });
expect(await screen.findByText('Saved ✓')).toBeInTheDocument();
});
it('marks invalid input without saving it', async () => {
const { state } = installFakeApi({ data: data(), loggedIn: true });
const user = userEvent.setup();
render(<App />);
const input = await screen.findByLabelText('Hours worked on 2026-10-07');
await user.type(input, 'abc');
expect(input).toHaveAttribute('aria-invalid', 'true');
await new Promise((r) => setTimeout(r, 700));
expect(state.saves).toHaveLength(0);
});
it('counts start–end ranges minus the break', async () => {
const { state } = installFakeApi({ data: data(), loggedIn: true });
const user = userEvent.setup();
render(<App />);
await screen.findByRole('heading', { name: /Week 41/ });
const wed = screen.getByRole('group', { name: 'Input mode for 2026-10-07' });
await user.click(within(wed).getByRole('button', { name: 'Start–End' }));
const row = wed.closest('.day') as HTMLElement;
await user.type(within(row).getByLabelText('Start'), '08:00');
await user.type(within(row).getByLabelText('End'), '16:00');
expect(within(row).getByText('7:30')).toBeInTheDocument(); // 8 h − 30 min default break
await waitFor(() =>
expect(state.data.entries['2026-10-07']).toEqual({ mode: 'range', start: '08:00', end: '16:00', breakMin: 30 }),
);
});
it('gives an off day no target', async () => {
installFakeApi({ data: data(), loggedIn: true });
const user = userEvent.setup();
render(<App />);
const wed = await screen.findByRole('group', { name: 'Input mode for 2026-10-07' });
await user.click(within(wed).getByRole('button', { name: 'Off' }));
expect(within(weekFooter()).getByText('29 h 00 min')).toBeInTheDocument();
});
});
describe('expired session', () => {
it('asks to log in again and then saves the pending change', async () => {
const { state } = installFakeApi({ data: data(), loggedIn: true });
const user = userEvent.setup();
render(<App />);
await screen.findByRole('heading', { name: /Week 41/ });
state.expireSession = true;
await user.type(screen.getByLabelText('Hours worked on 2026-10-07'), '6');
expect(await screen.findByText(/session expired/i)).toBeInTheDocument();
expect(state.data.entries['2026-10-07']).toBeUndefined();
await logIn(user);
await waitFor(() => expect(state.data.entries['2026-10-07']).toEqual({ mode: 'total', minutes: 360 }));
});
});
describe('creating an account', () => {
async function openSignUp(user: ReturnType<typeof userEvent.setup>) {
await user.click(await screen.findByRole('button', { name: 'Create an account' }));
}
it('signs up and lands in an empty tracker', async () => {
const { state } = installFakeApi({ data: data() });
const user = userEvent.setup();
render(<App />);
await openSignUp(user);
await user.type(screen.getByLabelText('Username'), 'newbie');
await user.type(screen.getByLabelText('Password'), 'long enough password');
await user.type(screen.getByLabelText('Repeat password'), 'long enough password');
await user.click(screen.getByRole('button', { name: 'Create account' }));
expect(await screen.findByRole('heading', { name: /Week 41/ })).toBeInTheDocument();
expect(screen.getByText('newbie')).toBeInTheDocument();
expect(within(weekFooter()).getByText('0 h 00 min')).toBeInTheDocument();
expect(state.users.has('newbie')).toBe(true);
});
it('checks the password length and confirmation before sending', async () => {
const { fetchMock } = installFakeApi({ data: data() });
const user = userEvent.setup();
render(<App />);
await openSignUp(user);
await user.type(screen.getByLabelText('Username'), 'newbie');
await user.type(screen.getByLabelText('Password'), 'short');
await user.type(screen.getByLabelText('Repeat password'), 'short');
await user.click(screen.getByRole('button', { name: 'Create account' }));
expect(screen.getByRole('alert')).toHaveTextContent('at least 12 characters');
await user.type(screen.getByLabelText('Password'), 'long enough password');
await user.type(screen.getByLabelText('Repeat password'), 'a different password');
await user.click(screen.getByRole('button', { name: 'Create account' }));
expect(screen.getByRole('alert')).toHaveTextContent('Passwords do not match');
expect(fetchMock).not.toHaveBeenCalledWith('/api/register', expect.anything());
});
it('shows the server error for a taken username', async () => {
installFakeApi({ data: data() });
const user = userEvent.setup();
render(<App />);
await openSignUp(user);
await user.type(screen.getByLabelText('Username'), 'aaro');
await user.type(screen.getByLabelText('Password'), 'long enough password');
await user.type(screen.getByLabelText('Repeat password'), 'long enough password');
await user.click(screen.getByRole('button', { name: 'Create account' }));
expect(await screen.findByRole('alert')).toHaveTextContent('That username is taken');
});
it('asks for an invite code when the server requires one', async () => {
installFakeApi({ data: data(), registration: { open: true, inviteRequired: true } });
const user = userEvent.setup();
render(<App />);
await openSignUp(user);
expect(screen.getByLabelText('Invite code')).toBeRequired();
});
it('hides sign-up when registration is closed', async () => {
installFakeApi({ data: data(), registration: { open: false, inviteRequired: false } });
render(<App />);
await screen.findByRole('button', { name: 'Log in' });
await waitFor(() => expect(screen.queryByRole('button', { name: 'Create an account' })).toBeNull());
});
});
describe('deleting an account', () => {
async function openDelete(user: ReturnType<typeof userEvent.setup>) {
await screen.findByRole('heading', { name: /Week 41/ });
await user.click(screen.getByText('Account'));
await user.click(screen.getByRole('button', { name: 'Delete account…' }));
}
it('can be cancelled', async () => {
const { state } = installFakeApi({ data: data(), loggedIn: true });
const user = userEvent.setup();
render(<App />);
await openDelete(user);
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(screen.getByRole('button', { name: 'Delete account…' })).toBeInTheDocument();
expect(state.users.has('aaro')).toBe(true);
});
it('needs the right password, then deletes and returns to the login screen', async () => {
const { state } = installFakeApi({ data: data(), loggedIn: true });
const user = userEvent.setup();
render(<App />);
await openDelete(user);
const deleteButton = screen.getByRole('button', { name: 'Delete my account' });
expect(deleteButton).toBeDisabled();
await user.type(screen.getByLabelText('Enter your password to confirm'), 'wrong');
await user.click(deleteButton);
expect(await screen.findByRole('alert')).toHaveTextContent('Wrong password');
expect(state.users.has('aaro')).toBe(true);
await user.type(screen.getByLabelText('Enter your password to confirm'), 'secret password');
await user.click(screen.getByRole('button', { name: 'Delete my account' }));
expect(await screen.findByText('Your account and all its data were deleted.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument();
expect(state.users.has('aaro')).toBe(false);
});
it('drops a not-yet-saved change instead of writing it after deletion', async () => {
const { state } = installFakeApi({ data: data(), loggedIn: true });
const user = userEvent.setup();
render(<App />);
await openDelete(user);
// Type a change, then delete before the 500 ms save debounce fires.
await user.type(screen.getByLabelText('Hours worked on 2026-10-07'), '6');
await user.type(screen.getByLabelText('Enter your password to confirm'), 'secret password');
await user.click(screen.getByRole('button', { name: 'Delete my account' }));
await screen.findByText('Your account and all its data were deleted.');
await new Promise((r) => setTimeout(r, 700));
expect(state.saves).toHaveLength(0);
});
});
describe('expired session with another user', () => {
it('never saves one user’s pending change into another account', async () => {
const { state } = installFakeApi({ data: data(), loggedIn: true });
state.users.set('other', { password: 'other password!', data: data() });
const user = userEvent.setup();
render(<App />);
await screen.findByRole('heading', { name: /Week 41/ });
state.expireSession = true;
await user.type(screen.getByLabelText('Hours worked on 2026-10-07'), '6');
await screen.findByText(/session expired/i);
await logIn(user, 'other password!', 'other');
await screen.findByText('other');
await new Promise((r) => setTimeout(r, 700));
expect(state.saves.filter((s) => s.username === 'other')).toHaveLength(0);
expect(state.users.get('other')!.data.entries['2026-10-07']).toBeUndefined();
});
});
+75
View File
@@ -0,0 +1,75 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { afterEach, describe, expect, it } from 'vitest';
import type { Data } from '../../shared/types';
import { Settings } from '../../frontend/components/Settings';
afterEach(cleanup);
const initial: Data = {
settings: {
fullTimeWeekMinutes: 2175,
workdays: [1, 2, 3, 4, 5],
trackingStart: '2026-10-05',
openingBalanceMinutes: 0,
},
workloadPeriods: [{ from: '2026-01-01', percent: 100 }],
entries: {},
};
function Harness({ onData }: { onData: (d: Data) => void }) {
const [data, setData] = useState(initial);
return (
<Settings
data={data}
today="2026-10-08"
onChange={(d) => {
setData(d);
onData(d);
}}
/>
);
}
describe('Settings', () => {
it('adds a 50% period and shows its weekly hours', async () => {
let latest = initial;
const user = userEvent.setup();
render(<Harness onData={(d) => (latest = d)} />);
await user.click(screen.getByText('Settings'));
await user.click(screen.getByRole('button', { name: '+ Add period' }));
expect(latest.workloadPeriods).toEqual([
{ from: '2026-01-01', percent: 100 },
{ from: '2026-10-08', percent: 50 },
]);
expect(screen.getByText('= 18:08 h/week')).toBeInTheDocument();
});
it('changes a period to 25% and clamps the percentage', async () => {
let latest = initial;
const user = userEvent.setup();
render(<Harness onData={(d) => (latest = d)} />);
await user.click(screen.getByText('Settings'));
const pct = screen.getByDisplayValue('100');
fireEvent.change(pct, { target: { value: '25' } });
expect(latest.workloadPeriods[0].percent).toBe(25);
fireEvent.change(pct, { target: { value: '250' } });
expect(latest.workloadPeriods[0].percent).toBe(100);
});
it('accepts a negative opening balance', async () => {
let latest = initial;
const user = userEvent.setup();
render(<Harness onData={(d) => (latest = d)} />);
await user.click(screen.getByText('Settings'));
const input = screen.getByPlaceholderText('e.g. -2:30');
await user.clear(input);
await user.type(input, '-2:30');
expect(latest.settings.openingBalanceMinutes).toBe(-150);
});
});
+137
View File
@@ -0,0 +1,137 @@
import { describe, expect, it } from 'vitest';
import type { Data } from '../../shared/types';
import {
dayTarget,
formatDuration,
formatHM,
history,
isoWeekNumber,
mondayOf,
parseDuration,
rangeMinutes,
weekSummary,
} from '../../frontend/calc';
// 2026-10-05 is a Monday (ISO week 41).
const MON = '2026-10-05';
function data(overrides: Partial<Data> = {}): Data {
return {
settings: {
fullTimeWeekMinutes: 2175,
workdays: [1, 2, 3, 4, 5],
trackingStart: MON,
openingBalanceMinutes: 0,
},
workloadPeriods: [{ from: '2026-01-01', percent: 100 }],
entries: {},
...overrides,
};
}
const fullWeek = (minutes: number) =>
Object.fromEntries(['05', '06', '07', '08', '09'].map((d) => [`2026-10-${d}`, { mode: 'total' as const, minutes }]));
describe('parseDuration', () => {
it.each([
['7.5', 450],
['7,25', 435],
['7:15', 435],
['7h15', 435],
['7h 15min', 435],
['7h', 420],
['45m', 45],
['-1:30', -90],
['8', 480],
])('%s -> %i', (input, expected) => expect(parseDuration(input)).toBe(expected));
it.each(['', 'abc', '7:75', '7h99'])('rejects %j', (input) => expect(parseDuration(input)).toBeNull());
});
describe('formatting', () => {
it('formats h:mm and long durations', () => {
expect(formatHM(435)).toBe('7:15');
expect(formatHM(-90)).toBe('-1:30');
expect(formatDuration(65, true)).toBe('+1 h 05 min');
expect(formatDuration(-45, true)).toBe('−0 h 45 min');
expect(formatDuration(0, true)).toBe('0 h 00 min');
});
});
describe('dates', () => {
it('finds the Monday and ISO week', () => {
expect(mondayOf('2026-10-11')).toBe(MON);
expect(mondayOf(MON)).toBe(MON);
expect(isoWeekNumber(MON)).toBe(41);
expect(isoWeekNumber('2027-01-01')).toBe(53);
});
});
describe('rangeMinutes', () => {
it('subtracts the break and handles past-midnight shifts', () => {
expect(rangeMinutes('08:00', '16:00', 30)).toBe(450);
expect(rangeMinutes('22:00', '02:00', 0)).toBe(240);
expect(rangeMinutes('', '16:00', 0)).toBe(0);
});
});
describe('targets', () => {
it('is 7:15 per weekday at 100% and 0 at weekends', () => {
const d = data();
expect(dayTarget(MON, d)).toBe(435);
expect(dayTarget('2026-10-10', d)).toBe(0);
expect(weekSummary(MON, d).target).toBe(2175);
});
it('follows 50% and 25% workload periods', () => {
const d = data({
workloadPeriods: [
{ from: '2026-01-01', percent: 100 },
{ from: '2026-10-07', percent: 50 },
{ from: '2026-10-09', percent: 25 },
],
});
expect(dayTarget('2026-10-06', d)).toBe(435);
expect(dayTarget('2026-10-07', d)).toBe(217.5);
expect(dayTarget('2026-10-09', d)).toBe(108.75);
expect(weekSummary(MON, d).target).toBe(435 * 2 + 217.5 * 2 + 108.75);
});
it('gives off days a zero target', () => {
const d = data({ entries: { [MON]: { mode: 'off', note: 'vacation' } } });
expect(dayTarget(MON, d)).toBe(0);
expect(weekSummary(MON, d).target).toBe(2175 - 435);
});
});
describe('weekSummary and history', () => {
it('is even for a full 7:15 week', () => {
const d = data({ entries: fullWeek(435) });
expect(weekSummary(MON, d)).toMatchObject({ worked: 2175, target: 2175, diff: 0 });
});
it('accumulates over/under hours across weeks with an opening balance', () => {
const d = data({
settings: { ...data().settings, openingBalanceMinutes: 30 },
entries: {
...fullWeek(495), // +60 min/day -> +5 h
'2026-10-12': { mode: 'total', minutes: 375 }, // -60 min
},
});
const { rows, balance } = history(d, '2026-10-12');
expect(rows).toHaveLength(2);
expect(rows[0]).toMatchObject({ diff: 300, balance: 330 });
expect(rows[1]).toMatchObject({ diff: -60, balance: 270 });
expect(balance).toBe(270);
});
it('ignores days after today and before the tracking start', () => {
const d = data({ settings: { ...data().settings, trackingStart: '2026-10-07' } });
// Wed + Thu counted, nothing logged -> -2 × 7:15
expect(history(d, '2026-10-08').balance).toBe(-870);
});
it('is empty when tracking starts in the future', () => {
expect(history(data(), '2026-10-01')).toEqual({ rows: [], balance: 0 });
});
});
+96
View File
@@ -0,0 +1,96 @@
import { vi } from 'vitest';
import { defaultData, type Data } from '../../shared/types';
interface FakeUser {
password: string;
data: Data;
/** Data version, bumped on every save (the server's ETag). */
rev?: number;
}
/** In-memory stand-in for the server API, installed as global fetch. */
export function installFakeApi(opts: {
data: Data;
/** Start with this user already logged in (cookie present). */
loggedIn?: boolean;
registration?: { open: boolean; inviteRequired: boolean };
}) {
const state = {
users: new Map<string, FakeUser>([['aaro', { password: 'secret password', data: structuredClone(opts.data) }]]),
session: (opts.loggedIn ? 'aaro' : null) as string | null,
saves: [] as { username: string; data: Data }[],
/** When true, the next request that needs a session gets 401 (simulates expiry). */
expireSession: false,
get data() {
return this.users.get('aaro')?.data as Data;
},
};
const registration = opts.registration ?? { open: true, inviteRequired: false };
const reply = (status: number, body?: unknown, headers: Record<string, string> = {}) =>
Promise.resolve(
new Response(body === undefined ? null : JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json', ...headers },
}),
);
const etag = (u: FakeUser) => ({ ETag: `"${u.rev ?? 0}"` });
const notLoggedIn = () => reply(401, { error: 'Not logged in' });
const fetchMock = vi.fn<(url: string, init?: RequestInit) => Promise<Response>>((url, init = {}) => {
const method = init.method ?? 'GET';
const body = init.body ? JSON.parse(init.body as string) : undefined;
if (state.expireSession && url !== '/api/login') {
state.session = null;
state.expireSession = false;
}
const me = state.session ? state.users.get(state.session) : undefined;
switch (`${method} ${url}`) {
case 'GET /api/registration':
return reply(200, registration);
case 'POST /api/register': {
const name = String(body.username).toLowerCase();
if (state.users.has(name)) return reply(409, { error: 'That username is taken' });
state.users.set(name, { password: body.password, data: defaultData() });
state.session = name;
return reply(201, { username: name });
}
case 'POST /api/login': {
const name = String(body.username).toLowerCase();
if (state.users.get(name)?.password !== body.password) {
return reply(401, { error: 'Wrong username or password' });
}
state.session = name;
return reply(200, { username: name });
}
case 'POST /api/logout':
state.session = null;
return reply(204);
case 'GET /api/session':
return me ? reply(200, { username: state.session }) : notLoggedIn();
case 'DELETE /api/account':
if (!me) return notLoggedIn();
if (body.password !== me.password) return reply(403, { error: 'Wrong password' });
state.users.delete(state.session!);
state.session = null;
return reply(204);
case 'GET /api/data':
return me ? reply(200, me.data, etag(me)) : notLoggedIn();
case 'PUT /api/data': {
if (!me) return notLoggedIn();
const ifMatch = (init.headers as Record<string, string> | undefined)?.['If-Match'];
if (ifMatch !== etag(me).ETag) return reply(412, me.data, etag(me));
me.data = body;
me.rev = (me.rev ?? 0) + 1;
state.saves.push({ username: state.session!, data: body });
return reply(200, { ok: true }, etag(me));
}
default:
return reply(404, { error: 'Not found' });
}
});
vi.stubGlobal('fetch', fetchMock);
return { state, fetchMock };
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import type { Data } from '../../shared/types';
import { mergeData } from '../../frontend/merge';
const base: Data = {
settings: {
fullTimeWeekMinutes: 2175,
workdays: [1, 2, 3, 4, 5],
trackingStart: '2026-10-05',
openingBalanceMinutes: 0,
},
workloadPeriods: [{ from: '2026-01-01', percent: 100 }],
entries: { '2026-10-05': { mode: 'total', minutes: 435 } },
};
describe('mergeData', () => {
it('keeps changes to different days from both sides', () => {
const mine = { ...base, entries: { ...base.entries, '2026-10-06': { mode: 'total' as const, minutes: 420 } } };
const theirs = { ...base, entries: { ...base.entries, '2026-10-05': { mode: 'total' as const, minutes: 480 } } };
expect(mergeData(base, mine, theirs).entries).toEqual({
'2026-10-05': { mode: 'total', minutes: 480 },
'2026-10-06': { mode: 'total', minutes: 420 },
});
});
it('lets my edit win when both sides changed the same day', () => {
const mine = { ...base, entries: { '2026-10-05': { mode: 'total' as const, minutes: 100 } } };
const theirs = { ...base, entries: { '2026-10-05': { mode: 'total' as const, minutes: 200 } } };
expect(mergeData(base, mine, theirs).entries['2026-10-05']).toEqual({ mode: 'total', minutes: 100 });
});
it('keeps deletions from either side', () => {
const mine = { ...base, entries: {} };
expect(mergeData(base, mine, base).entries).toEqual({});
expect(mergeData(base, base, mine).entries).toEqual({});
});
it('merges settings field by field and periods as a whole', () => {
const mine = { ...base, settings: { ...base.settings, openingBalanceMinutes: -60 } };
const theirs = {
...base,
settings: { ...base.settings, trackingStart: '2026-09-28' },
workloadPeriods: [...base.workloadPeriods, { from: '2026-11-01', percent: 50 }],
};
const merged = mergeData(base, mine, theirs);
expect(merged.settings).toMatchObject({ openingBalanceMinutes: -60, trackingStart: '2026-09-28' });
expect(merged.workloadPeriods).toHaveLength(2);
});
});