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

@@ -717,111 +717,6 @@ test('test that adding a timesheet break to a full day splits the work entry via
]);
});
test('test that inserting a break as long as the work keeps the tracked work time', async ({
page,
ctx,
}) => {
// A one-hour break splits but does not reduce the one-hour work entry.
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
const day = getCurrentWeekMonday().toISOString().slice(0, 10);
await createTimeEntryWithTimestampsViaApi(ctx, {
start: `${day}T09:00:00Z`,
end: `${day}T10:00:00Z`,
description: 'One hour',
});
await goToTimesheet(page);
await expect(page.getByTestId('timesheet_view')).toBeVisible();
const breakCell = await fillBreakCell(page, '1');
await breakCell.press('Enter');
await expect(page.getByTestId('break_placement_summary')).toBeVisible();
await expect(page.getByTestId('break_placement_infeasible')).not.toBeVisible();
await Promise.all([
waitForBreakCreated(page),
page.getByRole('button', { name: 'Add break' }).click(),
]);
const dayEntries = await getDayEntriesViaApi(ctx, day);
expect(dayEntries.map((e) => [e.type, e.start, e.end])).toEqual([
['work', `${day}T09:00:00Z`, `${day}T09:30:00Z`],
['break', `${day}T09:30:00Z`, `${day}T10:30:00Z`],
['work', `${day}T10:30:00Z`, `${day}T11:00:00Z`],
]);
const workRow = page
.locator('[data-testid="timesheet_row"]')
.filter({ hasNot: page.getByText('Break', { exact: true }) })
.first();
await expect(
workRow.locator('[data-testid="timesheet_cell"]').nth(0).locator('input')
).toHaveValue('1h 00min');
});
test('test that a late work entry starts earlier so the break still fits the day', async ({
page,
ctx,
}) => {
// Move the block earlier rather than extending it into the next day.
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
const day = getCurrentWeekMonday().toISOString().slice(0, 10);
await createTimeEntryWithTimestampsViaApi(ctx, {
start: `${day}T22:00:00Z`,
end: `${day}T23:30:00Z`,
description: 'Late shift',
});
await goToTimesheet(page);
await expect(page.getByTestId('timesheet_view')).toBeVisible();
const breakCell = await fillBreakCell(page, '1');
await breakCell.press('Enter');
await expect(page.getByTestId('break_placement_summary')).toBeVisible();
await expect(page.getByTestId('break_placement_infeasible')).not.toBeVisible();
await Promise.all([
waitForBreakCreated(page),
page.getByRole('button', { name: 'Add break' }).click(),
]);
const dayEntries = await getDayEntriesViaApi(ctx, day);
const nextDay = new Date(`${day}T00:00:00Z`);
nextDay.setUTCDate(nextDay.getUTCDate() + 1);
const midnight = `${nextDay.toISOString().slice(0, 10)}T00:00:00Z`;
expect(dayEntries.map((e) => [e.type, e.start, e.end])).toEqual([
['work', `${day}T21:30:00Z`, `${day}T22:15:00Z`],
['break', `${day}T22:15:00Z`, `${day}T23:15:00Z`],
['work', `${day}T23:15:00Z`, midnight],
]);
});
test('test that a work entry too short to split gets the break placed next to it', async ({
page,
ctx,
}) => {
// The one-minute work entry is too short to split.
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
const day = getCurrentWeekMonday().toISOString().slice(0, 10);
await createTimeEntryWithTimestampsViaApi(ctx, {
start: `${day}T09:00:00Z`,
end: `${day}T09:01:00Z`,
description: 'Quick note',
});
await goToTimesheet(page);
await expect(page.getByTestId('timesheet_view')).toBeVisible();
const breakCell = await fillBreakCell(page, '0.5');
await Promise.all([waitForBreakCreated(page), breakCell.press('Enter')]);
await expect(page.getByTestId('break_placement_summary')).not.toBeVisible();
const dayEntries = await getDayEntriesViaApi(ctx, day);
expect(dayEntries.map((e) => [e.type, e.start, e.end])).toEqual([
['work', `${day}T09:00:00Z`, `${day}T09:01:00Z`],
['break', `${day}T09:01:00Z`, `${day}T09:31:00Z`],
]);
});
test('test that adding a break into an oversized gap places it without moving other entries', async ({
page,
ctx,
@@ -917,44 +812,12 @@ test('test that the placement modal warns when the chosen time would leave the b
['work', `${day}T12:00:00Z`, `${day}T17:00:00Z`],
]);
// ...and the timesheet now shows the misaligned-break hint for that day
await expect(
page.getByRole('button', { name: 'does not align with your work entries' })
).toBeVisible();
});
test('test that a misplaced break shows a warning on its timesheet day cell', async ({
page,
ctx,
}) => {
// Work ends at 10:00 and the break starts hours later with no work after it,
// so it is misplaced and its day header should carry the warning hint.
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
const day = getCurrentWeekMonday().toISOString().slice(0, 10);
await createTimeEntryWithTimestampsViaApi(ctx, {
start: `${day}T09:00:00Z`,
end: `${day}T10:00:00Z`,
});
await createTimeEntryWithTimestampsViaApi(ctx, {
start: `${day}T14:00:00Z`,
end: `${day}T14:30:00Z`,
type: 'break',
});
await goToTimesheet(page);
await expect(page.getByTestId('timesheet_view')).toBeVisible();
// Exactly one warning, sitting in Monday's day header
const hint = page.getByRole('button', {
name: 'does not align with your work entries',
});
await expect(hint).toHaveCount(1);
await expect(
page.getByTestId('timesheet_day_header').first().getByRole('button', {
name: 'does not align with your work entries',
})
).toBeVisible();
await expect(hint).toBeVisible();
// The hint links to the calendar on the affected date
// The resulting warning links to the calendar on the affected date.
await hint.click();
await expect(page.getByRole('link', { name: 'Fix in calendar' })).toHaveAttribute(
'href',

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();
});
it('returns null when the group has no placement hints', () => {
const entries = [breakEntry('break-a'), breakEntry('break-b')];
expect(findMisplacedBreak(entries, {})).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('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),
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,
})
);
});
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.