cleanup and deduplicate breaks frontend tests

This commit is contained in:
Gregor Vostrak
2026-07-28 15:51:17 +02:00
parent 600daf44d9
commit 619c602571
5 changed files with 121 additions and 404 deletions

View File

@@ -2,14 +2,31 @@ import { describe, expect, it } from 'vitest';
import type { TimeEntry } from '@/packages/api/src';
import {
findMisplacedBreak,
getBreakPlacementHint,
type BreakPlacementHint,
} from '@/packages/ui/src/utils/breakPlacement';
// Decision logic behind the aggregate (collapsed grouped-break) row's placement
// warning: the row shows the hint — and navigates the calendar — based on the
// first misplaced break in the group.
function breakEntry(id: string): TimeEntry {
return { id, type: 'break', start: '2026-07-14T10:00:00Z' } as TimeEntry;
function entry(
id: string,
start: string,
end: string | null,
type: 'work' | 'break' = 'work'
): TimeEntry {
return {
id,
type,
start,
end,
duration: end ? (Date.parse(end) - Date.parse(start)) / 1000 : null,
organization_id: 'organization-1',
user_id: 'user-1',
member_id: 'member-1',
project_id: type === 'break' ? null : 'project-1',
task_id: null,
billable: false,
description: null,
tags: [],
};
}
function hint(misplaced: boolean): BreakPlacementHint {
@@ -24,34 +41,99 @@ function hint(misplaced: boolean): BreakPlacementHint {
describe('findMisplacedBreak', () => {
it('returns the first misplaced break in a group', () => {
const entries = [breakEntry('break-a'), breakEntry('break-b')];
const entries = [
entry('break-a', '2026-07-14T10:00:00Z', '2026-07-14T10:30:00Z', 'break'),
entry('break-b', '2026-07-14T12:00:00Z', '2026-07-14T12:30:00Z', 'break'),
];
const result = findMisplacedBreak(entries, {
'break-a': hint(false),
'break-b': hint(true),
});
expect(result?.id).toBe('break-b');
});
});
it('returns null when no break in the group is misplaced', () => {
const entries = [breakEntry('break-a'), breakEntry('break-b')];
const result = findMisplacedBreak(entries, {
'break-a': hint(false),
'break-b': hint(false),
});
expect(result).toBeNull();
describe('getBreakPlacementHint', () => {
const breakEntry = entry('break', '2026-07-14T12:00:00Z', '2026-07-14T12:30:00Z', 'break');
it('accepts work touching both sides of the break', () => {
const result = getBreakPlacementHint(breakEntry, [
entry('morning', '2026-07-14T09:00:00Z', '2026-07-14T12:00:00Z'),
entry('afternoon', '2026-07-14T12:30:00Z', '2026-07-14T17:00:00Z'),
]);
expect(result).toEqual(
expect.objectContaining({
misplaced: false,
gapBeforeSeconds: 0,
gapAfterSeconds: 0,
})
);
});
it('returns null when the group has no placement hints', () => {
const entries = [breakEntry('break-a'), breakEntry('break-b')];
expect(findMisplacedBreak(entries, {})).toBeNull();
it('accepts gaps exactly at the placement tolerance', () => {
const result = getBreakPlacementHint(breakEntry, [
entry('morning', '2026-07-14T09:00:00Z', '2026-07-14T11:30:00Z'),
entry('afternoon', '2026-07-14T13:00:00Z', '2026-07-14T17:00:00Z'),
]);
expect(result).toEqual(
expect.objectContaining({
misplaced: false,
gapBeforeSeconds: 30 * 60,
gapAfterSeconds: 30 * 60,
})
);
});
it('ignores hints for entries that are not in the group', () => {
const entries = [breakEntry('break-a')];
const result = findMisplacedBreak(entries, {
'break-a': hint(false),
'break-elsewhere': hint(true),
});
expect(result).toBeNull();
it('flags a completed break when work is missing on either side', () => {
const noPreviousWork = getBreakPlacementHint(breakEntry, [
entry('afternoon', '2026-07-14T12:30:00Z', '2026-07-14T17:00:00Z'),
]);
const noNextWork = getBreakPlacementHint(breakEntry, [
entry('morning', '2026-07-14T09:00:00Z', '2026-07-14T12:00:00Z'),
]);
expect(noPreviousWork).toEqual(
expect.objectContaining({ misplaced: true, gapBeforeSeconds: null })
);
expect(noNextWork).toEqual(
expect.objectContaining({ misplaced: true, gapAfterSeconds: null })
);
});
it('does not require work after a running break', () => {
const runningBreak = entry('break', '2026-07-14T12:00:00Z', null, 'break');
const result = getBreakPlacementHint(runningBreak, [
entry('morning', '2026-07-14T09:00:00Z', '2026-07-14T12:00:00Z'),
]);
expect(result).toEqual(
expect.objectContaining({
misplaced: false,
gapBeforeSeconds: 0,
gapAfterSeconds: null,
})
);
});
it('treats work overlapping the break as touching both sides', () => {
const result = getBreakPlacementHint(breakEntry, [
entry('overlapping', '2026-07-14T11:45:00Z', '2026-07-14T12:15:00Z'),
]);
expect(result).toEqual(
expect.objectContaining({
misplaced: false,
gapBeforeSeconds: 0,
gapAfterSeconds: 0,
})
);
});
it('returns null for work entries', () => {
expect(
getBreakPlacementHint(entry('work', '2026-07-14T09:00:00Z', '2026-07-14T10:00:00Z'), [])
).toBeNull();
});
});

View File

@@ -60,7 +60,7 @@ describe('decideBreakPlacement', () => {
});
});
it('delegates an empty day to the default cell placement', () => {
it('selects generic free-window placement for an empty day', () => {
expect(
decideBreakPlacement({
date: DAY,
@@ -88,6 +88,19 @@ describe('decideBreakPlacement', () => {
);
});
it('saves the break next to work too short to split', () => {
const decision = decideBreakPlacement({
date: DAY,
durationSeconds: HALF_HOUR,
context: context([movable('quick', 9, 9 + 1 / 60)]),
});
expect(decision).toEqual({
kind: 'save',
slot: { start: `${DAY}T09:01:00Z`, end: `${DAY}T09:31:00Z` },
});
});
it('rejects a day that cannot fit or rearrange the break', () => {
expect(
decideBreakPlacement({
@@ -190,15 +203,6 @@ describe('planSplitEntry', () => {
expect(workSeconds(plan!)).toBe(8 * HOUR);
});
it('inserts a break as long as the work without shortening it', () => {
const plan = planSplitEntry(iv(9, 10), HOUR);
expect(plan).not.toBeNull();
expect(plan!.firstHalf).toEqual({ start: `${DAY}T09:00:00Z`, end: `${DAY}T09:30:00Z` });
expect(plan!.breakSlot).toEqual({ start: `${DAY}T09:30:00Z`, end: `${DAY}T10:30:00Z` });
expect(plan!.secondHalf).toEqual({ start: `${DAY}T10:30:00Z`, end: `${DAY}T11:00:00Z` });
expect(workSeconds(plan!)).toBe(HOUR);
});
it('accepts a break far longer than the work entry', () => {
const plan = planSplitEntry(iv(9, 9.5), 4 * HOUR);
expect(plan).not.toBeNull();
@@ -279,15 +283,6 @@ describe('planSplitEntry', () => {
expect(workSeconds(plan!)).toBe(1.5 * HOUR);
});
it('fits a break that is longer than the work left in the day', () => {
const plan = planSplitEntry(iv(23, 24), HOUR, undefined, { dayStart, dayEnd });
expect(plan).not.toBeNull();
expect(plan!.firstHalf).toEqual({ start: `${DAY}T22:00:00Z`, end: `${DAY}T22:30:00Z` });
expect(plan!.breakSlot).toEqual({ start: `${DAY}T22:30:00Z`, end: `${DAY}T23:30:00Z` });
expect(plan!.secondHalf).toEqual({ start: `${DAY}T23:30:00Z`, end: MIDNIGHT });
expect(workSeconds(plan!)).toBe(HOUR);
});
it('reproduces its own plan when re-planned from the break start it chose', () => {
const options = { dayStart, dayEnd };
const suggested = planSplitEntry(iv(22, 23.5), HOUR, undefined, options);
@@ -349,17 +344,6 @@ describe('findBreakSlotNearInDay', () => {
});
});
it('slides only as far as the nearest free window allows', () => {
expect(findBreakSlotNearInDay(dayStart, dayEnd, HOUR, anchor, [iv(21.5, 22)])).toEqual({
start: `${DAY}T23:00:00Z`,
end: MIDNIGHT,
});
expect(findBreakSlotNearInDay(dayStart, dayEnd, 2 * HOUR, anchor, [iv(21.5, 22)])).toEqual({
start: `${DAY}T22:00:00Z`,
end: MIDNIGHT,
});
});
it('respects a day window shortened by a running entry', () => {
expect(findBreakSlotNearInDay(dayStart, `${DAY}T12:00:00Z`, HOUR, anchor)).toEqual({
start: `${DAY}T11:00:00Z`,
@@ -396,10 +380,6 @@ describe('findAdjacentBreakSlot', () => {
it('returns null when neither side fits inside the day', () => {
expect(findAdjacentBreakSlot([iv(0, 24)], dayStart, dayEnd, HALF_HOUR)).toBeNull();
});
it('returns null without any work to sit next to', () => {
expect(findAdjacentBreakSlot([], dayStart, dayEnd, HALF_HOUR)).toBeNull();
});
});
describe('planMoveInsert', () => {
@@ -425,32 +405,6 @@ describe('planMoveInsert', () => {
]);
});
it('leaves an oversized gap alone instead of pulling the right block flush', () => {
// 09-12 and 15-17 (3h gap). Break flush after first at 12:00 fits in the gap
// → nothing moves; the user's gap is preserved.
const plan = planMoveInsert(
[movable('a', 9, 12), movable('b', 15, 17)],
dayStart,
dayEnd,
`${DAY}T12:00:00Z`,
HALF_HOUR
);
expect(plan!.breakSlot).toEqual({ start: `${DAY}T12:00:00Z`, end: `${DAY}T12:30:00Z` });
expect(plan!.shifted).toEqual([]);
});
it('does not drag entries flush when the break sits mid-gap', () => {
// Break at 13:00 in the middle of the 12:00-15:00 gap → neither side moves.
const plan = planMoveInsert(
[movable('a', 9, 12), movable('b', 15, 17)],
dayStart,
dayEnd,
`${DAY}T13:00:00Z`,
HALF_HOUR
);
expect(plan!.shifted).toEqual([]);
});
it('shifts each side only as much as needed to clear the slot', () => {
// Break 14:45-15:15 overlaps only the start of 'b' → 'b' pushed 15m later,
// 'a' untouched.
@@ -666,12 +620,6 @@ describe('findValidBreakGapNear', () => {
expect(gap).toEqual({ start: `${DAY}T10:00:00Z`, end: `${DAY}T11:00:00Z` });
});
it('clamps the anchor into the tolerance window when it sits too late', () => {
// Anchored at 11:00 (beyond the window) → clamped back to 10:30.
const gap = findValidBreakGapNear(work, HOUR, `${DAY}T11:00:00Z`);
expect(gap).toEqual({ start: `${DAY}T10:30:00Z`, end: `${DAY}T11:30:00Z` });
});
it('keeps the break in place inside an oversized gap', () => {
// 09-10 and 14-15 → 4h gap. The break stays exactly where the user left it;
// its distance from work is a soft hint, not a reason to move it.

View File

@@ -155,49 +155,6 @@ describe('useBreakPlacement.placeBreak', () => {
);
});
it('parks the break next to work too short to split instead of refusing it', async () => {
const work = entry('2026-04-10T09:00:00Z', '2026-04-10T09:01:00Z', { id: 'w1' });
const { bp } = setup([work]);
await bp.placeBreak(breakRow, 0, HOUR / 2);
expect(apiMocks.createTimeEntry).toHaveBeenCalledTimes(1);
expect(apiMocks.createTimeEntry.mock.calls[0]![0]).toEqual(
expect.objectContaining({
type: 'break',
start: '2026-04-10T09:01:00Z',
end: '2026-04-10T09:31:00Z',
})
);
expect(bp.breakPlacementRequest.value).toBeNull();
});
it('still offers a split when a running entry caps the end of the day', async () => {
const work = entry('2026-04-10T09:00:00Z', '2026-04-10T12:00:00Z', { id: 'w1' });
const running = entry('2026-04-10T12:00:00Z', null, { id: 'running' });
const { bp } = setup([work, running]);
await expect(bp.placeBreak(breakRow, 0, HOUR)).resolves.toBe('needs-input');
expect(bp.breakPlacementRequest.value?.defaultBreakStart).toBe('2026-04-10T09:30:00Z');
await bp.applyBreakPlacement('2026-04-10T09:30:00Z', HOUR);
const created = apiMocks.createTimeEntry.mock.calls.map((c) => c[0]);
expect(created).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'work',
start: '2026-04-10T10:30:00Z',
end: '2026-04-10T12:00:00Z',
}),
expect.objectContaining({
type: 'break',
start: '2026-04-10T09:30:00Z',
end: '2026-04-10T10:30:00Z',
}),
])
);
});
it('drops a new break into the first free window when the day has no work', async () => {
const { bp, createCell } = setup([]);
await bp.placeBreak(breakRow, 0, HOUR);
@@ -223,41 +180,6 @@ describe('useBreakPlacement.placeBreak', () => {
);
});
it('slides a workless-day break earlier rather than growing it past midnight', async () => {
const brk = entry('2026-04-10T23:00:00Z', '2026-04-10T23:30:00Z', {
id: 'b1',
type: 'break',
});
const { bp, updateEntry } = setup([brk]);
await bp.placeBreak(breakRow, 0, 2 * HOUR, 'b1');
expect(updateEntry).toHaveBeenCalledWith(
expect.objectContaining({
id: 'b1',
start: '2026-04-10T22:00:00Z',
end: '2026-04-11T00:00:00Z',
})
);
});
it('refuses a workless-day resize the day genuinely cannot hold', async () => {
const brk = entry('2026-04-10T23:00:00Z', '2026-04-10T23:30:00Z', {
id: 'b1',
type: 'break',
});
const other = entry('2026-04-10T06:00:00Z', '2026-04-10T18:00:00Z', {
id: 'b2',
type: 'break',
});
const { bp, updateEntry } = setup([brk, other]);
await expect(bp.placeBreak(breakRow, 0, 20 * HOUR, 'b1')).rejects.toBeInstanceOf(
NoFreeWindowError
);
expect(updateEntry).not.toHaveBeenCalled();
});
it('requests input in the split modal when a single work entry blocks every gap', async () => {
const work = entry('2026-04-10T09:00:00Z', '2026-04-10T17:00:00Z', { id: 'w1' });
const { bp } = setup([work]);
@@ -320,63 +242,6 @@ describe('useBreakPlacement.applyBreakPlacement (split)', () => {
expect(addNotification).toHaveBeenCalledWith('success', 'Break added', expect.any(String));
});
it('keeps the work length when the break is as long as the work entry', async () => {
const work = entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z', { id: 'w1' });
const { bp, updateEntry } = setup([work]);
await bp.placeBreak(breakRow, 0, HOUR);
expect(bp.breakPlacementRequest.value?.defaultBreakStart).toBe('2026-04-10T09:30:00Z');
await bp.applyBreakPlacement('2026-04-10T09:30:00Z', HOUR);
expect(updateEntry).toHaveBeenCalledWith(
expect.objectContaining({
id: 'w1',
start: '2026-04-10T09:00:00Z',
end: '2026-04-10T09:30:00Z',
})
);
const created = apiMocks.createTimeEntry.mock.calls.map((c) => c[0]);
expect(created).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'work',
start: '2026-04-10T10:30:00Z',
end: '2026-04-10T11:00:00Z',
}),
expect.objectContaining({
type: 'break',
start: '2026-04-10T09:30:00Z',
end: '2026-04-10T10:30:00Z',
}),
])
);
});
it('accepts a break longer than the work entry', async () => {
const work = entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z', { id: 'w1' });
const { bp } = setup([work]);
await bp.placeBreak(breakRow, 0, 3 * HOUR);
await bp.applyBreakPlacement('2026-04-10T09:30:00Z', 3 * HOUR);
const created = apiMocks.createTimeEntry.mock.calls.map((c) => c[0]);
expect(created).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'work',
start: '2026-04-10T12:30:00Z',
end: '2026-04-10T13:00:00Z',
}),
expect.objectContaining({
type: 'break',
start: '2026-04-10T09:30:00Z',
end: '2026-04-10T12:30:00Z',
}),
])
);
expect(addNotification).toHaveBeenCalledWith('success', 'Break added', expect.any(String));
});
it('moves an existing break out of the way of the pushed-out work', async () => {
const work = entry('2026-04-10T09:00:00Z', '2026-04-10T12:00:00Z', { id: 'w1' });
const existingBreak = entry('2026-04-10T12:00:00Z', '2026-04-10T12:15:00Z', {
@@ -554,13 +419,6 @@ describe('useBreakPlacement.applyBreakPlacement (split)', () => {
);
expect(bp.breakPlacementRequest.value).toBeNull();
});
it('does nothing when there is no pending placement request', async () => {
const { bp, updateEntry } = setup([]);
await bp.applyBreakPlacement('2026-04-10T12:00:00Z', HOUR);
expect(updateEntry).not.toHaveBeenCalled();
expect(apiMocks.createTimeEntry).not.toHaveBeenCalled();
});
});
describe('useBreakPlacement.applyBreakPlacement (move)', () => {

View File

@@ -362,40 +362,6 @@ describe('useTimesheetCellMutations.handleCellUpdate', () => {
expect(apiMocks.createTimeEntry).not.toHaveBeenCalled();
});
it('keeps an edited break anchored to its position instead of recentering', async () => {
// 09-10 and 11:30-12:30 leave a 90-min gap; a resized 1h break has a valid
// window of 10:00-10:30. The break already starts at 10:00, so it must stay
// there (10:00-11:00) rather than jump to the centered 10:15-11:15.
const morning = entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z', {
id: 'morning',
type: 'work',
});
const afternoon = entry('2026-04-10T11:30:00Z', '2026-04-10T12:30:00Z', {
id: 'afternoon',
type: 'work',
});
const existingBreak = entry('2026-04-10T10:00:00Z', '2026-04-10T10:30:00Z', {
id: 'break-1',
project_id: null,
type: 'break',
});
const row = buildRow(null, [existingBreak], 'break-row');
row.type = 'break';
const { cellMutations } = setup([morning, afternoon, existingBreak]);
await cellMutations.handleCellUpdate(row, 0, HOUR);
expect(apiMocks.updateTimeEntry).toHaveBeenCalledTimes(1);
expect(firstArg(apiMocks.updateTimeEntry)).toEqual(
expect.objectContaining({
id: 'break-1',
start: '2026-04-10T10:00:00Z',
end: '2026-04-10T11:00:00Z',
})
);
expect(apiMocks.createTimeEntry).not.toHaveBeenCalled();
});
it('grows a multi-break cell by re-placing the latest break, not fragmenting', async () => {
// Two breaks share the break cell. Growing the cell total must extend the
// latest-ending break (break-b) in place — never create a third break entry.