Files
trip-plan/tests/timeline-layout.test.js
grabowski b0bdd2570c Add hourly day timeline and red now indicators to the calendar
Days with 4+ entries open with an hour-grid timeline in the day editor:
untimed entries in an all-day strip, timed ones positioned by start/end
with side-by-side columns for overlaps, click-to-edit. Layout math lives
in a pure, unit-tested module (timelineLayout.js).

Today's month-grid cell gets a vertical red slider positioned by the
fraction of the day elapsed; the timeline gets the classic horizontal
red current-time line. Both tick every minute with cleanup on unmount,
and the initial positioning runs unconditionally while the node is
still detached (the isConnected self-heal gates only interval ticks).

Also splits entryRow rendering out of dayEditor.js to stay under the
500-line cap.
2026-08-30 12:49:17 +02:00

250 lines
8.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { timedSlots, layoutColumns, gridWindow } from '../public/js/timelineLayout.js';
// Unit tests for the pure, DOM-free timeline layout helpers backing the hourly
// day timeline (docs/API.md "Hourly day timeline"). The DOM rendering in
// dayTimeline.js is not covered here — these pin the contract of the maths.
let nextId = 1;
function entry(over = {}) {
return {
id: nextId++, type: 'activity', title: 'Thing', date: '2026-08-01',
start_time: '09:00', end_time: '10:00', ...over,
};
}
// A raw slot as layoutColumns consumes it (what timedSlots produces).
function slot(title, startMin, endMin) {
return { entry: entry({ title, start_time: null }), startMin, endMin };
}
// Column results keyed by entry title, so assertions are independent of the
// order layoutColumns returns the slots in.
function colsByTitle(slots) {
const out = {};
for (const s of layoutColumns(slots)) out[s.entry.title] = { col: s.col, cols: s.cols };
return out;
}
// --- timedSlots ---
test('timedSlots: parses valid HH:MM times into minutes since midnight', () => {
const e = entry({ start_time: '09:30', end_time: '11:45' });
const slots = timedSlots([e]);
assert.equal(slots.length, 1);
assert.equal(slots[0].startMin, 9 * 60 + 30);
assert.equal(slots[0].endMin, 11 * 60 + 45);
assert.equal(slots[0].entry, e, 'the slot carries the original entry');
});
test('timedSlots: missing end_time defaults to one hour after the start', () => {
const slots = timedSlots([
entry({ start_time: '14:15', end_time: null }),
entry({ start_time: '14:15', end_time: undefined }),
entry({ start_time: '14:15', end_time: '' }),
]);
assert.equal(slots.length, 3);
for (const s of slots) {
assert.equal(s.startMin, 14 * 60 + 15);
assert.equal(s.endMin, 15 * 60 + 15);
}
});
test('timedSlots: an invalid end_time format is treated as missing (+60)', () => {
const slots = timedSlots([
entry({ start_time: '10:00', end_time: 'later' }),
entry({ start_time: '10:00', end_time: '10.30' }),
]);
assert.equal(slots.length, 2);
for (const s of slots) assert.equal(s.endMin, 11 * 60);
});
test('timedSlots: end_time at or before start_time clamps endMin to midnight (1440)', () => {
const slots = timedSlots([
entry({ start_time: '22:00', end_time: '01:30' }), // crosses midnight
entry({ start_time: '12:00', end_time: '12:00' }), // equal counts too
]);
assert.equal(slots.length, 2);
assert.equal(slots[0].startMin, 22 * 60);
assert.equal(slots[0].endMin, 1440);
assert.equal(slots[1].endMin, 1440);
});
test('timedSlots: an entry spanning into end_date clamps endMin to 1440', () => {
const slots = timedSlots([
entry({ date: '2026-08-01', end_date: '2026-08-02', start_time: '10:00', end_time: '11:00' }),
]);
assert.equal(slots.length, 1);
assert.equal(slots[0].startMin, 10 * 60);
assert.equal(slots[0].endMin, 1440);
});
test('timedSlots: entries with an invalid or absent start_time are excluded', () => {
const timed = entry({ title: 'timed', start_time: '08:00', end_time: '09:00' });
const slots = timedSlots([
entry({ start_time: null }),
entry({ start_time: undefined }),
entry({ start_time: '' }),
entry({ start_time: 'noon' }),
timed,
]);
assert.equal(slots.length, 1);
assert.equal(slots[0].entry, timed);
});
// --- layoutColumns ---
test('layoutColumns: non-overlapping slots all sit in column 0 of a 1-column row', () => {
const out = colsByTitle([
slot('a', 9 * 60, 10 * 60),
slot('b', 11 * 60, 12 * 60),
slot('c', 13 * 60, 14 * 60),
]);
assert.deepEqual(out, {
a: { col: 0, cols: 1 },
b: { col: 0, cols: 1 },
c: { col: 0, cols: 1 },
});
});
test('layoutColumns: two overlapping slots get distinct columns in a 2-column row', () => {
const out = colsByTitle([
slot('a', 9 * 60, 11 * 60),
slot('b', 10 * 60, 12 * 60),
]);
assert.equal(out.a.cols, 2);
assert.equal(out.b.cols, 2);
assert.notEqual(out.a.col, out.b.col);
assert.deepEqual([out.a.col, out.b.col].sort(), [0, 1]);
});
test('layoutColumns: an overlap chain shares one cluster, with column reuse', () => {
// a overlaps b, b overlaps c, but a and c do not touch: one connected
// cluster of width 2, and c reuses column 0 because a has ended by then.
const out = colsByTitle([
slot('a', 0, 100),
slot('b', 50, 150),
slot('c', 120, 200),
]);
assert.deepEqual(out, {
a: { col: 0, cols: 2 },
b: { col: 1, cols: 2 },
c: { col: 0, cols: 2 },
});
});
test('layoutColumns: back-to-back slots (end == next start) do not overlap', () => {
const out = colsByTitle([
slot('a', 60, 120),
slot('b', 120, 180),
]);
assert.deepEqual(out, {
a: { col: 0, cols: 1 },
b: { col: 0, cols: 1 },
});
});
test('layoutColumns: cols is per overlap cluster, not a global maximum', () => {
// Three separate clusters in one call: a 2-wide pair, an isolated slot, and
// a 3-wide pile-up. Each slot's cols must reflect only its own cluster.
const out = colsByTitle([
slot('a1', 9 * 60, 10 * 60),
slot('a2', 9 * 60 + 30, 10 * 60 + 30),
slot('lone', 11 * 60, 12 * 60),
slot('b1', 13 * 60, 16 * 60),
slot('b2', 13 * 60 + 30, 15 * 60),
slot('b3', 14 * 60, 14 * 60 + 30),
]);
assert.equal(out.a1.cols, 2);
assert.equal(out.a2.cols, 2);
assert.deepEqual(out.lone, { col: 0, cols: 1 },
'an isolated slot is full-width even when other clusters split');
assert.deepEqual([out.b1.cols, out.b2.cols, out.b3.cols], [3, 3, 3]);
});
test('layoutColumns: three-way overlap widens the whole cluster to 3 columns', () => {
const out = colsByTitle([
slot('a', 9 * 60, 12 * 60),
slot('b', 10 * 60, 11 * 60),
slot('c', 10 * 60 + 30, 11 * 60 + 30),
]);
assert.deepEqual([out.a.cols, out.b.cols, out.c.cols], [3, 3, 3]);
assert.deepEqual([out.a.col, out.b.col, out.c.col].sort(), [0, 1, 2]);
});
// --- gridWindow ---
test('gridWindow: empty slots fall back to the 08:0021:00 default window', () => {
assert.deepEqual(gridWindow([]), { startHour: 8, endHour: 21 });
});
test('gridWindow: pads one full hour either side of the slots', () => {
const win = gridWindow([slot('a', 9 * 60 + 30, 17 * 60 + 45)]);
assert.deepEqual(win, { startHour: 8, endHour: 19 });
});
test('gridWindow: exact-hour boundaries still get a full hour of padding', () => {
const win = gridWindow([slot('a', 9 * 60, 17 * 60)]);
assert.deepEqual(win, { startHour: 8, endHour: 18 });
});
test('gridWindow: clamps to 0 and 24 at the edges of the day', () => {
assert.deepEqual(gridWindow([slot('a', 15, 23 * 60 + 40)]), { startHour: 0, endHour: 24 });
assert.deepEqual(gridWindow([slot('a', 0, 1440)]), { startHour: 0, endHour: 24 });
});
test('gridWindow: spans from the earliest start to the latest end across slots', () => {
const win = gridWindow([
slot('a', 11 * 60, 12 * 60),
slot('b', 7 * 60 + 10, 8 * 60),
slot('c', 13 * 60, 20 * 60 + 5),
]);
assert.deepEqual(win, { startHour: 6, endHour: 22 });
});
// --- determinism & purity ---
test('timedSlots: repeat calls agree and the input entries are untouched', () => {
const make = () => [
entry({ id: 1, title: 'x', start_time: '10:00', end_time: 'bogus' }),
entry({ id: 2, title: 'y', start_time: null }),
entry({ id: 3, title: 'z', start_time: '08:00', end_time: '07:00' }),
];
const input = make();
const snapshot = JSON.stringify(input);
const a = timedSlots(input);
const b = timedSlots(input);
assert.deepEqual(
a.map((s) => [s.entry.id, s.startMin, s.endMin]),
b.map((s) => [s.entry.id, s.startMin, s.endMin])
);
assert.equal(JSON.stringify(input), snapshot, 'entries are not mutated');
});
test('layoutColumns: deliberately unsorted input yields the sorted-input layout', () => {
// Same chain as the cluster test, fed out of order: the greedy pass sorts
// by startMin then endMin internally, so the assignments must not change.
const unsorted = [
slot('c', 120, 200),
slot('a', 0, 100),
slot('b', 50, 150),
];
const before = [...unsorted];
const out = colsByTitle(unsorted);
assert.deepEqual(out, {
a: { col: 0, cols: 2 },
b: { col: 1, cols: 2 },
c: { col: 0, cols: 2 },
});
assert.deepEqual(unsorted, before, 'the input array order is not disturbed');
// And a second identical call agrees exactly.
const again = colsByTitle([
slot('c', 120, 200),
slot('a', 0, 100),
slot('b', 50, 150),
]);
assert.deepEqual(again, out);
});