mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
685 lines
27 KiB
TypeScript
685 lines
27 KiB
TypeScript
import dayjs from 'dayjs';
|
||
import utc from 'dayjs/plugin/utc';
|
||
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
BREAK_GAP_TOLERANCE_SECONDS,
|
||
buildDayPlacementContext,
|
||
decideBreakPlacement,
|
||
findAdjacentBreakSlot,
|
||
findBreakSlotNearInDay,
|
||
findValidBreakGap,
|
||
findValidBreakGapNear,
|
||
planMoveInsert,
|
||
planSplitEntry,
|
||
suggestMovePlan,
|
||
type MovableInterval,
|
||
} from './breakPlacementMath';
|
||
|
||
dayjs.extend(utc);
|
||
|
||
const HALF_HOUR = 1800;
|
||
const HOUR = 3600;
|
||
const DAY = '2026-07-14';
|
||
const dayStart = `${DAY}T00:00:00Z`;
|
||
const dayEnd = `${DAY}T24:00:00Z`;
|
||
const MIDNIGHT = '2026-07-15T00:00:00Z';
|
||
|
||
function iv(startH: number, endH: number) {
|
||
const h = (n: number) => {
|
||
const totalMin = Math.round(n * 60);
|
||
const hh = Math.floor(totalMin / 60);
|
||
const mm = totalMin % 60;
|
||
return `${DAY}T${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}:00Z`;
|
||
};
|
||
return { start: h(startH), end: h(endH) };
|
||
}
|
||
|
||
describe('decideBreakPlacement', () => {
|
||
const context = (work: MovableInterval[] = []) => ({
|
||
work,
|
||
breaks: [],
|
||
blocked: [],
|
||
dayStart,
|
||
dayEnd: MIDNIGHT,
|
||
});
|
||
const movable = (id: string, startH: number, endH: number): MovableInterval => ({
|
||
id,
|
||
...iv(startH, endH),
|
||
});
|
||
|
||
it('returns a direct save when an existing gap fits', () => {
|
||
const decision = decideBreakPlacement({
|
||
date: DAY,
|
||
durationSeconds: HOUR,
|
||
context: context([movable('morning', 9, 12), movable('afternoon', 13, 17)]),
|
||
});
|
||
|
||
expect(decision).toEqual({
|
||
kind: 'save',
|
||
slot: { start: `${DAY}T12:00:00Z`, end: `${DAY}T13:00:00Z` },
|
||
});
|
||
});
|
||
|
||
it('selects generic free-window placement for an empty day', () => {
|
||
expect(
|
||
decideBreakPlacement({
|
||
date: DAY,
|
||
durationSeconds: HOUR,
|
||
context: context(),
|
||
})
|
||
).toEqual({ kind: 'place-in-free-window' });
|
||
});
|
||
|
||
it('returns a modal request when work must be rearranged', () => {
|
||
const decision = decideBreakPlacement({
|
||
date: DAY,
|
||
durationSeconds: HOUR,
|
||
context: context([movable('work', 9, 17)]),
|
||
});
|
||
|
||
expect(decision).toEqual(
|
||
expect.objectContaining({
|
||
kind: 'needs-input',
|
||
request: expect.objectContaining({
|
||
date: DAY,
|
||
defaultBreakStart: `${DAY}T13:00:00Z`,
|
||
}),
|
||
})
|
||
);
|
||
});
|
||
|
||
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({
|
||
date: DAY,
|
||
durationSeconds: HOUR,
|
||
context: context([movable('all-day', 0, 24)]),
|
||
})
|
||
).toEqual({ kind: 'reject' });
|
||
});
|
||
});
|
||
|
||
describe('findValidBreakGap', () => {
|
||
it('centers the break in a gap that fits within tolerance', () => {
|
||
// 09-12 and 13-17 → 1h gap, 30m break → centered at 12:15-12:45
|
||
const gap = findValidBreakGap([iv(9, 12), iv(13, 17)], HALF_HOUR);
|
||
expect(gap).toEqual({ start: `${DAY}T12:15:00Z`, end: `${DAY}T12:45:00Z` });
|
||
});
|
||
|
||
it('rejects a gap that is too small for the break', () => {
|
||
// 09-12 and 12:15-17 → 15m gap, 30m break does not fit
|
||
expect(findValidBreakGap([iv(9, 12), iv(12.25, 17)], HALF_HOUR)).toBeNull();
|
||
});
|
||
|
||
it('places the break flush after work in an oversized gap instead of rejecting it', () => {
|
||
// 09-12 and 14-17 → 2h gap. No placement keeps both sides within tolerance,
|
||
// but the gap easily holds the break — place it flush after the first entry
|
||
// and leave the gap otherwise untouched (distance to work is only a soft hint).
|
||
expect(findValidBreakGap([iv(9, 12), iv(14, 17)], HALF_HOUR)).toEqual({
|
||
start: `${DAY}T12:00:00Z`,
|
||
end: `${DAY}T12:30:00Z`,
|
||
});
|
||
});
|
||
|
||
it('prefers a within-tolerance gap over an earlier oversized gap', () => {
|
||
// 09-10, 13-14, 15-16: the first gap (3h) is oversized, the second (1h) is
|
||
// valid → center in the second instead of going flush-left in the first.
|
||
expect(findValidBreakGap([iv(9, 10), iv(13, 14), iv(15, 16)], HALF_HOUR)).toEqual({
|
||
start: `${DAY}T14:15:00Z`,
|
||
end: `${DAY}T14:45:00Z`,
|
||
});
|
||
});
|
||
|
||
it('slides past an obstacle when placing into an oversized gap', () => {
|
||
// 09-12 and 16-17 with an existing break flush at 12:00 → the new break
|
||
// lands right after that break.
|
||
expect(findValidBreakGap([iv(9, 12), iv(16, 17)], HALF_HOUR, [iv(12, 12.75)])).toEqual({
|
||
start: `${DAY}T12:45:00Z`,
|
||
end: `${DAY}T13:15:00Z`,
|
||
});
|
||
});
|
||
|
||
it('does not fabricate a gap from an entry contained in a longer one', () => {
|
||
// 10-11 sits inside 09-17; the only real gap is 17:00-18:00 → centered there.
|
||
expect(findValidBreakGap([iv(9, 17), iv(10, 11), iv(18, 19)], HALF_HOUR)).toEqual({
|
||
start: `${DAY}T17:15:00Z`,
|
||
end: `${DAY}T17:45:00Z`,
|
||
});
|
||
});
|
||
|
||
it('accepts a gap exactly at duration + 2*tolerance', () => {
|
||
const gapEnd = 12 + (HALF_HOUR + 2 * BREAK_GAP_TOLERANCE_SECONDS) / HOUR;
|
||
const gap = findValidBreakGap([iv(9, 12), iv(gapEnd, gapEnd + 1)], HALF_HOUR);
|
||
expect(gap).not.toBeNull();
|
||
});
|
||
|
||
it('returns null when there is only one work entry', () => {
|
||
expect(findValidBreakGap([iv(9, 17)], HALF_HOUR)).toBeNull();
|
||
});
|
||
|
||
it('skips a gap already occupied by another break', () => {
|
||
// The only valid gap (12:15–12:45) is taken by an existing break → no auto placement
|
||
expect(
|
||
findValidBreakGap([iv(9, 12), iv(13, 17)], HALF_HOUR, [
|
||
{ start: `${DAY}T12:15:00Z`, end: `${DAY}T12:45:00Z` },
|
||
])
|
||
).toBeNull();
|
||
});
|
||
|
||
it('ignores obstacles that fall outside the chosen gap', () => {
|
||
expect(findValidBreakGap([iv(9, 12), iv(13, 17)], HALF_HOUR, [iv(20, 21)])).toEqual({
|
||
start: `${DAY}T12:15:00Z`,
|
||
end: `${DAY}T12:45:00Z`,
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('planSplitEntry', () => {
|
||
const workSeconds = (plan: NonNullable<ReturnType<typeof planSplitEntry>>) =>
|
||
dayjs.utc(plan.firstHalf.end).diff(dayjs.utc(plan.firstHalf.start), 'second') +
|
||
dayjs.utc(plan.secondHalf.end).diff(dayjs.utc(plan.secondHalf.start), 'second');
|
||
|
||
it('splits a single entry in the middle and keeps the work length', () => {
|
||
const plan = planSplitEntry(iv(9, 17), HALF_HOUR);
|
||
expect(plan).not.toBeNull();
|
||
expect(plan!.firstHalf.start).toBe(`${DAY}T09:00:00Z`);
|
||
expect(plan!.breakSlot.start).toBe(plan!.firstHalf.end);
|
||
expect(plan!.secondHalf.start).toBe(plan!.breakSlot.end);
|
||
expect(plan!.breakSlot).toEqual({ start: `${DAY}T13:00:00Z`, end: `${DAY}T13:30:00Z` });
|
||
expect(plan!.secondHalf.end).toBe(`${DAY}T17:30:00Z`);
|
||
expect(workSeconds(plan!)).toBe(8 * HOUR);
|
||
});
|
||
|
||
it('accepts a break far longer than the work entry', () => {
|
||
const plan = planSplitEntry(iv(9, 9.5), 4 * HOUR);
|
||
expect(plan).not.toBeNull();
|
||
expect(plan!.firstHalf).toEqual({ start: `${DAY}T09:00:00Z`, end: `${DAY}T09:15:00Z` });
|
||
expect(plan!.breakSlot).toEqual({ start: `${DAY}T09:15:00Z`, end: `${DAY}T13:15:00Z` });
|
||
expect(plan!.secondHalf).toEqual({ start: `${DAY}T13:15:00Z`, end: `${DAY}T13:30:00Z` });
|
||
expect(workSeconds(plan!)).toBe(HALF_HOUR);
|
||
});
|
||
|
||
it('honors an explicit break start', () => {
|
||
const plan = planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T10:00:00Z`);
|
||
expect(plan!.firstHalf).toEqual({ start: `${DAY}T09:00:00Z`, end: `${DAY}T10:00:00Z` });
|
||
expect(plan!.breakSlot).toEqual({ start: `${DAY}T10:00:00Z`, end: `${DAY}T10:30:00Z` });
|
||
expect(plan!.secondHalf).toEqual({ start: `${DAY}T10:30:00Z`, end: `${DAY}T17:30:00Z` });
|
||
expect(workSeconds(plan!)).toBe(8 * HOUR);
|
||
});
|
||
|
||
it('returns null when the entry is too short to leave work on both sides', () => {
|
||
expect(
|
||
planSplitEntry({ start: `${DAY}T09:00:00Z`, end: `${DAY}T09:01:30Z` }, HALF_HOUR)
|
||
).toBeNull();
|
||
});
|
||
|
||
it('rejects an explicit break start before the entry instead of clamping it', () => {
|
||
expect(planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T07:00:00Z`)).toBeNull();
|
||
});
|
||
|
||
it('rejects an explicit break start at or after the end of the entry', () => {
|
||
expect(planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T17:00:00Z`)).toBeNull();
|
||
expect(planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T18:00:00Z`)).toBeNull();
|
||
});
|
||
|
||
it('rejects an explicit break start that leaves less than the minimum fragment', () => {
|
||
expect(planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T09:00:30Z`)).toBeNull();
|
||
expect(planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T16:59:30Z`)).toBeNull();
|
||
});
|
||
|
||
it('accepts an explicit break start leaving exactly the minimum fragment on each side', () => {
|
||
const plan = planSplitEntry(iv(9, 9 + 2 / 60), HALF_HOUR, `${DAY}T09:01:00Z`);
|
||
expect(plan).not.toBeNull();
|
||
expect(plan!.firstHalf).toEqual({ start: `${DAY}T09:00:00Z`, end: `${DAY}T09:01:00Z` });
|
||
expect(plan!.secondHalf).toEqual({ start: `${DAY}T09:31:00Z`, end: `${DAY}T09:32:00Z` });
|
||
});
|
||
|
||
it('leaves later entries alone when the extended work does not reach them', () => {
|
||
const plan = planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T12:00:00Z`, {
|
||
otherEntries: [
|
||
{ id: 'early', ...iv(8, 8.5) },
|
||
{ id: 'later', ...iv(18, 19) },
|
||
],
|
||
});
|
||
expect(plan!.secondHalf).toEqual({ start: `${DAY}T12:30:00Z`, end: `${DAY}T17:30:00Z` });
|
||
expect(plan!.shifted).toEqual([]);
|
||
});
|
||
|
||
it('pushes only the collision chain and preserves later entry times', () => {
|
||
const plan = planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T12:00:00Z`, {
|
||
otherEntries: [
|
||
{ id: 'first', ...iv(17.25, 17.5) },
|
||
{ id: 'second', ...iv(17.6, 17.9) },
|
||
{ id: 'distant', ...iv(20, 21) },
|
||
],
|
||
});
|
||
|
||
// Moving the first break also collides with the second.
|
||
expect(plan!.shifted).toEqual([
|
||
{ id: 'first', ...iv(17.5, 17.75) },
|
||
{ id: 'second', ...iv(17.75, 18.05) },
|
||
]);
|
||
});
|
||
|
||
it('starts the work earlier when pushing it later would leave the day', () => {
|
||
const plan = planSplitEntry(iv(22, 23.5), HOUR, undefined, { dayStart, dayEnd });
|
||
expect(plan).not.toBeNull();
|
||
expect(plan!.firstHalf).toEqual({ start: `${DAY}T21:30:00Z`, end: `${DAY}T22:15:00Z` });
|
||
expect(plan!.breakSlot).toEqual({ start: `${DAY}T22:15:00Z`, end: `${DAY}T23:15:00Z` });
|
||
expect(plan!.secondHalf).toEqual({ start: `${DAY}T23:15:00Z`, end: MIDNIGHT });
|
||
expect(workSeconds(plan!)).toBe(1.5 * 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);
|
||
const replanned = planSplitEntry(iv(22, 23.5), HOUR, suggested!.breakSlot.start, options);
|
||
expect(replanned).toEqual(suggested);
|
||
});
|
||
|
||
it('returns null when the block cannot fit in the day at all', () => {
|
||
expect(planSplitEntry(iv(0, 23), 2 * HOUR, undefined, { dayStart, dayEnd })).toBeNull();
|
||
});
|
||
|
||
it('returns null when starting earlier would run into an entry that stays put', () => {
|
||
expect(
|
||
planSplitEntry(iv(22, 23.75), HOUR, undefined, {
|
||
dayStart,
|
||
dayEnd,
|
||
otherEntries: [{ id: 'earlier', ...iv(21, 21.5) }],
|
||
})
|
||
).toBeNull();
|
||
});
|
||
|
||
it('returns null when the actual collision chain would leave the day', () => {
|
||
expect(
|
||
planSplitEntry(iv(22, 23.5), HOUR, undefined, {
|
||
dayStart,
|
||
dayEnd,
|
||
otherEntries: [{ id: 'late', ...iv(23.75, 24) }],
|
||
})
|
||
).toBeNull();
|
||
});
|
||
|
||
it('does not reject a valid split because of an unrelated late entry', () => {
|
||
const plan = planSplitEntry(iv(9, 10), HOUR, `${DAY}T09:30:00Z`, {
|
||
dayStart,
|
||
dayEnd,
|
||
otherEntries: [{ id: 'late', ...iv(23.5, 24) }],
|
||
});
|
||
|
||
expect(plan).not.toBeNull();
|
||
expect(plan!.secondHalf.end).toBe(`${DAY}T11:00:00Z`);
|
||
expect(plan!.shifted).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('findBreakSlotNearInDay', () => {
|
||
const anchor = `${DAY}T23:00:00Z`;
|
||
|
||
it('keeps the break where it is when the new duration still fits', () => {
|
||
expect(findBreakSlotNearInDay(dayStart, dayEnd, HALF_HOUR, anchor)).toEqual({
|
||
start: anchor,
|
||
end: `${DAY}T23:30:00Z`,
|
||
});
|
||
});
|
||
|
||
it('slides the break earlier instead of growing past midnight', () => {
|
||
expect(findBreakSlotNearInDay(dayStart, dayEnd, 2 * HOUR, anchor)).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`,
|
||
end: `${DAY}T12:00:00Z`,
|
||
});
|
||
});
|
||
|
||
it('returns null when the day has no free window big enough', () => {
|
||
expect(findBreakSlotNearInDay(dayStart, dayEnd, 20 * HOUR, anchor, [iv(6, 18)])).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('findAdjacentBreakSlot', () => {
|
||
it('parks the break flush after the last work entry', () => {
|
||
expect(findAdjacentBreakSlot([iv(9, 9.5)], dayStart, dayEnd, HALF_HOUR)).toEqual({
|
||
start: `${DAY}T09:30:00Z`,
|
||
end: `${DAY}T10:00:00Z`,
|
||
});
|
||
});
|
||
|
||
it('falls back to flush before the first work entry', () => {
|
||
expect(findAdjacentBreakSlot([iv(9, 24)], dayStart, dayEnd, HALF_HOUR)).toEqual({
|
||
start: `${DAY}T08:30:00Z`,
|
||
end: `${DAY}T09:00:00Z`,
|
||
});
|
||
});
|
||
|
||
it('skips a candidate blocked by an existing break', () => {
|
||
expect(
|
||
findAdjacentBreakSlot([iv(9, 24)], dayStart, dayEnd, HALF_HOUR, [iv(8.75, 9)])
|
||
).toBeNull();
|
||
});
|
||
|
||
it('returns null when neither side fits inside the day', () => {
|
||
expect(findAdjacentBreakSlot([iv(0, 24)], dayStart, dayEnd, HALF_HOUR)).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('planMoveInsert', () => {
|
||
const movable = (id: string, startH: number, endH: number): MovableInterval => ({
|
||
id,
|
||
...iv(startH, endH),
|
||
});
|
||
|
||
it('pushes the right block later to open a slot for the break', () => {
|
||
// Back-to-back 09-12 and 12-17. Insert 30m break at 12:00.
|
||
const plan = planMoveInsert(
|
||
[movable('a', 9, 12), movable('b', 12, 17)],
|
||
dayStart,
|
||
dayEnd,
|
||
`${DAY}T12:00:00Z`,
|
||
HALF_HOUR
|
||
);
|
||
expect(plan).not.toBeNull();
|
||
expect(plan!.breakSlot).toEqual({ start: `${DAY}T12:00:00Z`, end: `${DAY}T12:30:00Z` });
|
||
// 'a' untouched (not in shifted), 'b' shifted +30m
|
||
expect(plan!.shifted).toEqual([
|
||
{ id: 'b', start: `${DAY}T12:30:00Z`, end: `${DAY}T17:30:00Z` },
|
||
]);
|
||
});
|
||
|
||
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.
|
||
const plan = planMoveInsert(
|
||
[movable('a', 9, 12), movable('b', 15, 17)],
|
||
dayStart,
|
||
dayEnd,
|
||
`${DAY}T14:45:00Z`,
|
||
HALF_HOUR
|
||
);
|
||
expect(plan!.shifted).toEqual([
|
||
{ id: 'b', start: `${DAY}T15:15:00Z`, end: `${DAY}T17:15:00Z` },
|
||
]);
|
||
});
|
||
|
||
it('pulls the left block earlier only when it overlaps the slot', () => {
|
||
// Break 11:45-12:15 overlaps the end of 'a' → 'a' pulled 15m earlier;
|
||
// 'b' (15-17) already clears the slot and stays put.
|
||
const plan = planMoveInsert(
|
||
[movable('a', 9, 12), movable('b', 15, 17)],
|
||
dayStart,
|
||
dayEnd,
|
||
`${DAY}T11:45:00Z`,
|
||
HALF_HOUR
|
||
);
|
||
expect(plan!.shifted).toEqual([
|
||
{ id: 'a', start: `${DAY}T08:45:00Z`, end: `${DAY}T11:45:00Z` },
|
||
]);
|
||
});
|
||
|
||
it('shifts the left block earlier when the right block cannot move within the day', () => {
|
||
// Right entry ends at 23:50; pushing it later would cross midnight, so the
|
||
// left block must move earlier instead.
|
||
const plan = planMoveInsert(
|
||
[movable('a', 9, 12), movable('b', 12, 23 + 50 / 60)],
|
||
dayStart,
|
||
dayEnd,
|
||
`${DAY}T12:00:00Z`,
|
||
HALF_HOUR
|
||
);
|
||
// Not feasible by pushing right; solver returns null (caller lets the user pick another spot)
|
||
expect(plan).toBeNull();
|
||
});
|
||
|
||
it('returns null when the break itself would fall outside the day', () => {
|
||
expect(
|
||
planMoveInsert([movable('a', 9, 12)], dayStart, dayEnd, `${DAY}T23:50:00Z`, HALF_HOUR)
|
||
).toBeNull();
|
||
});
|
||
|
||
it('places a break between entries without shifting when they already have exactly the gap', () => {
|
||
const plan = planMoveInsert(
|
||
[movable('a', 9, 12), movable('b', 12.5, 17)],
|
||
dayStart,
|
||
dayEnd,
|
||
`${DAY}T12:00:00Z`,
|
||
HALF_HOUR
|
||
);
|
||
// gap is exactly 30m → 'b' already starts at break end, nothing to shift
|
||
expect(plan!.breakSlot).toEqual({ start: `${DAY}T12:00:00Z`, end: `${DAY}T12:30:00Z` });
|
||
expect(plan!.shifted).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('suggestMovePlan', () => {
|
||
const movable = (id: string, startH: number, endH: number): MovableInterval => ({
|
||
id,
|
||
...iv(startH, endH),
|
||
});
|
||
|
||
it('finds a flush-after placement for back-to-back entries', () => {
|
||
const plan = suggestMovePlan(
|
||
[movable('a', 9, 12), movable('b', 12, 17)],
|
||
dayStart,
|
||
dayEnd,
|
||
HALF_HOUR
|
||
);
|
||
expect(plan!.breakSlot).toEqual({ start: `${DAY}T12:00:00Z`, end: `${DAY}T12:30:00Z` });
|
||
});
|
||
|
||
it('falls back to a flush-before placement when the day is nearly full at the end', () => {
|
||
// 09-12 and 12-23:50: pushing right past midnight is impossible, so the break
|
||
// is placed just before the second entry, pulling the first entry earlier.
|
||
const plan = suggestMovePlan(
|
||
[movable('a', 9, 12), movable('b', 12, 23 + 50 / 60)],
|
||
dayStart,
|
||
dayEnd,
|
||
HALF_HOUR
|
||
);
|
||
expect(plan).not.toBeNull();
|
||
expect(plan!.breakSlot).toEqual({ start: `${DAY}T11:30:00Z`, end: `${DAY}T12:00:00Z` });
|
||
// first entry pulled 30m earlier, second untouched
|
||
expect(plan!.shifted.find((s) => s.id === 'a')).toEqual({
|
||
id: 'a',
|
||
start: `${DAY}T08:30:00Z`,
|
||
end: `${DAY}T11:30:00Z`,
|
||
});
|
||
});
|
||
|
||
it('returns null when the day is completely full', () => {
|
||
const plan = suggestMovePlan([movable('a', 0, 24)], dayStart, dayEnd, HALF_HOUR);
|
||
expect(plan).toBeNull();
|
||
});
|
||
|
||
it('moves existing breaks along with the surrounding work', () => {
|
||
// Fully packed day: work 09-12, break 12-12:30, work 12:30-17. Opening a
|
||
// slot after the morning work pushes the existing break and the afternoon
|
||
// work later together — the plan never lands on top of the break.
|
||
const plan = suggestMovePlan(
|
||
[movable('a', 9, 12), movable('c', 12.5, 17)],
|
||
dayStart,
|
||
dayEnd,
|
||
HALF_HOUR,
|
||
[movable('x', 12, 12.5)]
|
||
);
|
||
expect(plan!.breakSlot).toEqual({ start: `${DAY}T12:00:00Z`, end: `${DAY}T12:30:00Z` });
|
||
expect([...plan!.shifted].sort((a, b) => a.id.localeCompare(b.id))).toEqual([
|
||
{ id: 'c', start: `${DAY}T13:00:00Z`, end: `${DAY}T17:30:00Z` },
|
||
{ id: 'x', start: `${DAY}T12:30:00Z`, end: `${DAY}T13:00:00Z` },
|
||
]);
|
||
});
|
||
});
|
||
|
||
describe('buildDayPlacementContext', () => {
|
||
const PREV_DAY = '2026-07-13';
|
||
const entry = (id: string, start: string, end: string | null, type = 'work') => ({
|
||
id,
|
||
start,
|
||
end,
|
||
type,
|
||
});
|
||
|
||
it('separates movable work and breaks fully inside the day', () => {
|
||
const ctx = buildDayPlacementContext(
|
||
[
|
||
entry('w1', `${DAY}T09:00:00Z`, `${DAY}T12:00:00Z`),
|
||
entry('b1', `${DAY}T12:00:00Z`, `${DAY}T12:30:00Z`, 'break'),
|
||
entry('w2', `${DAY}T13:00:00Z`, `${DAY}T17:00:00Z`),
|
||
],
|
||
dayStart,
|
||
dayEnd
|
||
);
|
||
expect(ctx.work.map((e) => e.id)).toEqual(['w1', 'w2']);
|
||
expect(ctx.breaks.map((e) => e.id)).toEqual(['b1']);
|
||
expect(ctx.dayStart).toBe(`${DAY}T00:00:00Z`);
|
||
// `T24:00` normalizes to the next day's midnight
|
||
expect(ctx.dayEnd).toBe(`2026-07-15T00:00:00Z`);
|
||
});
|
||
|
||
it('excludes the break being re-placed', () => {
|
||
const ctx = buildDayPlacementContext(
|
||
[entry('b1', `${DAY}T12:00:00Z`, `${DAY}T12:30:00Z`, 'break')],
|
||
dayStart,
|
||
dayEnd,
|
||
'b1'
|
||
);
|
||
expect(ctx.breaks).toEqual([]);
|
||
});
|
||
|
||
it('turns entries crossing midnight into walls that shrink the day window', () => {
|
||
// 22:00 (prev day) - 02:00 spills in; 23:00 - 01:00 (next day) spills out.
|
||
const ctx = buildDayPlacementContext(
|
||
[
|
||
entry('overnight', `${PREV_DAY}T22:00:00Z`, `${DAY}T02:00:00Z`),
|
||
entry('w1', `${DAY}T09:00:00Z`, `${DAY}T17:00:00Z`),
|
||
entry('late', `${DAY}T23:00:00Z`, `2026-07-15T01:00:00Z`),
|
||
],
|
||
dayStart,
|
||
dayEnd
|
||
);
|
||
// Boundary-crossers are not movable...
|
||
expect(ctx.work.map((e) => e.id)).toEqual(['w1']);
|
||
// ...but clamp the usable window so nothing can be shifted into them.
|
||
expect(ctx.dayStart).toBe(`${DAY}T02:00:00Z`);
|
||
expect(ctx.dayEnd).toBe(`${DAY}T23:00:00Z`);
|
||
});
|
||
|
||
it('ignores entries on other days', () => {
|
||
const ctx = buildDayPlacementContext(
|
||
[entry('other-day', `${PREV_DAY}T09:00:00Z`, `${PREV_DAY}T10:00:00Z`)],
|
||
dayStart,
|
||
dayEnd
|
||
);
|
||
expect(ctx.work).toEqual([]);
|
||
expect(ctx.breaks).toEqual([]);
|
||
expect(ctx.blocked).toEqual([]);
|
||
});
|
||
|
||
it('turns a running entry into a blocker that caps the day window', () => {
|
||
const ctx = buildDayPlacementContext(
|
||
[
|
||
entry('w1', `${DAY}T06:00:00Z`, `${DAY}T08:00:00Z`),
|
||
entry('running', `${DAY}T09:00:00Z`, null),
|
||
],
|
||
dayStart,
|
||
dayEnd
|
||
);
|
||
// The running entry is not movable, blocks the day from its start on,
|
||
// and nothing can be shifted to or past it.
|
||
expect(ctx.work.map((e) => e.id)).toEqual(['w1']);
|
||
expect(ctx.blocked).toEqual([{ start: `${DAY}T09:00:00Z`, end: dayEnd }]);
|
||
expect(ctx.dayEnd).toBe(`${DAY}T09:00:00Z`);
|
||
});
|
||
});
|
||
|
||
describe('findValidBreakGapNear', () => {
|
||
// 09-10 and 11:30-12:30 → a 90-min gap (10:00-11:30). A 1h break has a valid
|
||
// start window of 10:00-10:30; findValidBreakGap would center it at 10:15.
|
||
const work = [iv(9, 10), iv(11.5, 12.5)];
|
||
|
||
it('keeps the break at its current start instead of recentering', () => {
|
||
const gap = findValidBreakGapNear(work, HOUR, `${DAY}T10:00:00Z`);
|
||
expect(gap).toEqual({ start: `${DAY}T10:00:00Z`, end: `${DAY}T11:00: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.
|
||
const wideWork = [iv(9, 10), iv(14, 15)];
|
||
expect(findValidBreakGapNear(wideWork, HALF_HOUR, `${DAY}T11:00:00Z`)).toEqual({
|
||
start: `${DAY}T11:00:00Z`,
|
||
end: `${DAY}T11:30:00Z`,
|
||
});
|
||
});
|
||
|
||
it('clamps the anchor so the break stays inside the gap', () => {
|
||
// Anchored at 13:50 in the 10:00-14:00 gap → a 30m break would spill into
|
||
// the next work entry, so it is clamped back to 13:30-14:00.
|
||
const wideWork = [iv(9, 10), iv(14, 15)];
|
||
expect(findValidBreakGapNear(wideWork, HALF_HOUR, `${DAY}T13:50:00Z`)).toEqual({
|
||
start: `${DAY}T13:30:00Z`,
|
||
end: `${DAY}T14:00:00Z`,
|
||
});
|
||
});
|
||
|
||
it("returns null when the anchor's gap can't hold the new duration", () => {
|
||
// 09-10 and 10:30-12:30 → only a 30-min gap; a 1h break no longer fits.
|
||
const tightWork = [iv(9, 10), iv(10.5, 12.5)];
|
||
expect(findValidBreakGapNear(tightWork, HOUR, `${DAY}T10:00:00Z`)).toBeNull();
|
||
});
|
||
|
||
it('returns null when the anchor sits outside every inter-work gap', () => {
|
||
// Anchor before the first work entry — a genuinely misplaced break, which the
|
||
// caller then re-places via findValidBreakGap instead.
|
||
expect(findValidBreakGapNear(work, HOUR, `${DAY}T08:00:00Z`)).toBeNull();
|
||
});
|
||
|
||
it('returns null when no free window in the gap can hold the break', () => {
|
||
// Another break occupies 10:00-11:00; the leftover windows (none before,
|
||
// 30m after) can't hold a 1h break → fall back to findValidBreakGap.
|
||
expect(findValidBreakGapNear(work, HOUR, `${DAY}T10:00:00Z`, [iv(10, 11)])).toBeNull();
|
||
});
|
||
|
||
it('slides past a neighboring break inside the same gap instead of bailing', () => {
|
||
// Gap 10:00-12:00 between work; another break sits at 10:30-11:00. Growing
|
||
// the 10:00 break to 45m no longer fits before it, so it settles right
|
||
// after the neighbor (11:00) — not in a different gap across the day.
|
||
const wideWork = [iv(9, 10), iv(12, 13)];
|
||
expect(findValidBreakGapNear(wideWork, 2700, `${DAY}T10:00:00Z`, [iv(10.5, 11)])).toEqual({
|
||
start: `${DAY}T11:00:00Z`,
|
||
end: `${DAY}T11:45:00Z`,
|
||
});
|
||
});
|
||
|
||
it('settles in the free window closest to the anchor', () => {
|
||
// Gap 10:00-13:00 with obstacles 10:45-11:00 and 11:15-12:30. For a 30m
|
||
// break anchored at 10:50 the candidates are 10:15 (35m away) and 12:30
|
||
// (100m away); the middle window is too small.
|
||
const wideWork = [iv(9, 10), iv(13, 14)];
|
||
expect(
|
||
findValidBreakGapNear(wideWork, HALF_HOUR, `${DAY}T10:50:00Z`, [
|
||
iv(10.75, 11),
|
||
iv(11.25, 12.5),
|
||
])
|
||
).toEqual({ start: `${DAY}T10:15:00Z`, end: `${DAY}T10:45:00Z` });
|
||
});
|
||
});
|