mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
fix(assignments): keep untimed stops in place when a start is saved
Saving a start on a stop rewrote the whole day as the timed stops in time order followed by every untimed stop. Its own comment and the commit that added it promised that untimed stops keep their place, but a day of two untimed stops and a third one given 14:00 came back with the third one first. On a road trip the stop order is the drive, so the day was re-routed, its vias landed on other legs, and collaborators, who only got the one edited row, saw a third order until they reloaded. The intended rule, an untimed stop inheriting the time of the timed stop before it, now lives once as chronoOrder in @trek/shared, used by both the day plan (dayMerge) and AssignmentsService.updateTime. The server still reads a booked night's hour from its check-in, and a value without a colon still sorts after every clock time. The day is sorted only when the stop's start actually changed, so taking an End off or saving an unchanged start no longer snaps back a day somebody dragged out of time order. A sorted day is numbered from 0 like a drag. In the same transaction the day's road trip vias follow the stop they were drawn behind, the planner's own drag rule, and a via behind a last stop that stays last is kept, so the drive into the next day keeps its road. PUT /assignments/:id/time and the MCP update_assignment_time tool both broadcast assignment:reordered with the whole day, to the writer as well, and roadtripVia:changed when vias moved, so every client ends up with the same order. ASG-SVC-022, 023 and 027 pinned the old behaviour and now pin the intended one.
This commit is contained in:
@@ -43,6 +43,11 @@ const { db } = vi.hoisted(() => {
|
||||
check_out TEXT, confirmation TEXT, notes TEXT, created_at TEXT DEFAULT (datetime('now')));`);
|
||||
tmp.exec(`CREATE TABLE assignment_participants (assignment_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
|
||||
UNIQUE(assignment_id, user_id));`);
|
||||
// A start that reorders a day re-pins that day's vias in the same transaction, so
|
||||
// the sort reads this table whenever it moves a stop.
|
||||
tmp.exec(`CREATE TABLE roadtrip_vias (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL,
|
||||
after_order_index INTEGER NOT NULL, sequence INTEGER NOT NULL DEFAULT 0, lat REAL NOT NULL, lng REAL NOT NULL,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP);`);
|
||||
tmp.exec(`CREATE TABLE tags (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, color TEXT, created_at TEXT);`);
|
||||
tmp.exec(`CREATE TABLE place_tags (place_id INTEGER NOT NULL, tag_id INTEGER NOT NULL);`);
|
||||
// StorageRegistryService (behind StorageModule, now in this module chain) reads
|
||||
@@ -55,7 +60,8 @@ const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
|
||||
vi.mock('../../src/db/database', () => ({
|
||||
db, canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast }));
|
||||
|
||||
const { reconcileTripSkeletons } = vi.hoisted(() => ({ reconcileTripSkeletons: vi.fn() }));
|
||||
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
|
||||
@@ -213,6 +219,77 @@ describe('Assignments e2e (real auth guard + temp SQLite)', () => {
|
||||
expect(reconcileTripSkeletons).toHaveBeenCalledWith(5, undefined);
|
||||
});
|
||||
|
||||
describe('PUT /:id/time and the order of the day', () => {
|
||||
const seedDay = (times: (string | null)[]) => times.map((time, i) => {
|
||||
const id = seedAssignment(3, 2, i);
|
||||
if (time) db.prepare('UPDATE day_assignments SET assignment_time = ? WHERE id = ?').run(time, id);
|
||||
return id;
|
||||
});
|
||||
const dayOrder = () =>
|
||||
(db.prepare('SELECT id FROM day_assignments WHERE day_id = 3 ORDER BY order_index, id').all() as { id: number }[]).map(r => r.id);
|
||||
const eventsSent = () => broadcast.mock.calls.map(call => call[1]);
|
||||
|
||||
beforeEach(() => broadcast.mockClear());
|
||||
|
||||
it('keeps the untimed stops in front of the stop that gets a start', async () => {
|
||||
const [a, b, c] = seedDay([null, null, null]);
|
||||
const res = await request(server)
|
||||
.put(`/api/trips/5/assignments/${c}/time`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.set('X-Socket-Id', 'sock-1')
|
||||
.send({ place_time: '14:00', end_time: null });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.assignment).toMatchObject({ id: c, assignment_time: '14:00', order_index: 2 });
|
||||
expect(dayOrder()).toEqual([a, b, c]);
|
||||
expect(eventsSent()).toEqual(['assignment:updated']);
|
||||
});
|
||||
|
||||
it('sorts the timed stops, keeps the untimed head first and sends the whole day', async () => {
|
||||
const [a, b, c] = seedDay([null, '15:00', null]);
|
||||
const res = await request(server)
|
||||
.put(`/api/trips/5/assignments/${c}/time`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.set('X-Socket-Id', 'sock-1')
|
||||
.send({ place_time: '10:00', end_time: null });
|
||||
expect(res.status).toBe(200);
|
||||
expect(dayOrder()).toEqual([a, c, b]);
|
||||
// No socket left out, so the writer gets the order too.
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'assignment:reordered', { dayId: 3, orderedIds: [a, c, b] }, undefined);
|
||||
// No located stops and no vias on this day, so there is nothing to re-pin.
|
||||
expect(eventsSent()).not.toContain('roadtripVia:changed');
|
||||
});
|
||||
|
||||
it('stores the order it sends: a day with a gap in its keys is numbered from 0', async () => {
|
||||
// The gap a deleted stop leaves. Clients number the ids they are sent by position.
|
||||
const [a, b, c] = [0, 4, 7].map(key => seedAssignment(3, 2, key));
|
||||
db.prepare('UPDATE day_assignments SET assignment_time = ? WHERE id = ?').run('15:00', b);
|
||||
const res = await request(server)
|
||||
.put(`/api/trips/5/assignments/${c}/time`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ place_time: '10:00', end_time: null });
|
||||
expect(res.status).toBe(200);
|
||||
const sent = broadcast.mock.calls.find(call => call[1] === 'assignment:reordered')?.[2] as { orderedIds: number[] };
|
||||
expect(sent.orderedIds).toEqual([a, c, b]);
|
||||
const keyOf = (id: number) => (db.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(id) as { order_index: number }).order_index;
|
||||
expect(sent.orderedIds.map(keyOf)).toEqual([0, 1, 2]);
|
||||
expect(res.body.assignment).toMatchObject({ id: c, order_index: 1 });
|
||||
});
|
||||
|
||||
it('leaves a day dragged out of time order alone when only the End changes', async () => {
|
||||
const [a, b] = seedDay(['14:00', '10:00']);
|
||||
db.prepare("UPDATE day_assignments SET assignment_end_time = '11:00' WHERE id = ?").run(b);
|
||||
const res = await request(server)
|
||||
.put(`/api/trips/5/assignments/${b}/time`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.set('X-Socket-Id', 'sock-1')
|
||||
.send({ place_time: '10:00', end_time: null });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.assignment).toMatchObject({ id: b, assignment_time: '10:00', assignment_end_time: null });
|
||||
expect(dayOrder()).toEqual([a, b]);
|
||||
expect(eventsSent()).toEqual(['assignment:updated']);
|
||||
});
|
||||
});
|
||||
|
||||
it('200 participants (access-only)', async () => {
|
||||
const id = seedAssignment();
|
||||
db.prepare('INSERT INTO assignment_participants (assignment_id, user_id) VALUES (?, 2)').run(id);
|
||||
|
||||
@@ -403,6 +403,75 @@ describe('Tool: update_assignment_time', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Same service method as PUT /assignments/:id/time, so the same order and the same
|
||||
// three events. A: untimed, B: 15:00, C: untimed, D gets 10:00.
|
||||
it('keeps untimed stops in place, sorts the timed ones and sends the day and its vias like REST', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const [a, b, c, d] = [0, 1, 2, 3].map(i => createDayAssignment(testDb, day.id, place.id, { order_index: i }).id);
|
||||
testDb.prepare('UPDATE day_assignments SET assignment_time = ? WHERE id = ?').run('15:00', b);
|
||||
const via = Number(testDb.prepare(
|
||||
'INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?, 1, 0, 48.1, 11.5)'
|
||||
).run(day.id).lastInsertRowid);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'update_assignment_time', arguments: { tripId: trip.id, assignmentId: d, place_time: '10:00' } });
|
||||
expect((parseToolResult(result) as { assignment: { assignment_time: string } }).assignment.assignment_time).toBe('10:00');
|
||||
});
|
||||
|
||||
const order = (testDb.prepare('SELECT id FROM day_assignments WHERE day_id = ? ORDER BY order_index').all(day.id) as { id: number }[]).map(r => r.id);
|
||||
expect(order).toEqual([a, d, b, c]);
|
||||
expect(testDb.prepare('SELECT after_order_index FROM roadtrip_vias WHERE id = ?').get(via)).toEqual({ after_order_index: 2 });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:reordered', expect.objectContaining({ dayId: day.id, orderedIds: [a, d, b, c] }));
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'roadtripVia:changed', expect.objectContaining({
|
||||
dayId: day.id,
|
||||
vias: [expect.objectContaining({ id: via, after_order_index: 2 })],
|
||||
}));
|
||||
});
|
||||
|
||||
it('sends only the row when the start leaves the day as it was', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const [a, b, c] = [0, 1, 2].map(i => createDayAssignment(testDb, day.id, place.id, { order_index: i }).id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'update_assignment_time', arguments: { tripId: trip.id, assignmentId: c, place_time: '14:00' } });
|
||||
});
|
||||
|
||||
const order = (testDb.prepare('SELECT id FROM day_assignments WHERE day_id = ? ORDER BY order_index').all(day.id) as { id: number }[]).map(r => r.id);
|
||||
expect(order).toEqual([a, b, c]);
|
||||
expect(broadcastMock.mock.calls.map(call => call[1])).toEqual(['assignment:updated']);
|
||||
});
|
||||
|
||||
it('leaves a day out of time order as it is when the call names only the end', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
// B starts before A and was put behind it on purpose.
|
||||
const [a, b] = [0, 1].map(i => createDayAssignment(testDb, day.id, place.id, { order_index: i }).id);
|
||||
testDb.prepare("UPDATE day_assignments SET assignment_time = '14:00' WHERE id = ?").run(a);
|
||||
testDb.prepare("UPDATE day_assignments SET assignment_time = '10:00' WHERE id = ?").run(b);
|
||||
const via = Number(testDb.prepare(
|
||||
'INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?, 0, 0, 48.1, 11.5)'
|
||||
).run(day.id).lastInsertRowid);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'update_assignment_time', arguments: { tripId: trip.id, assignmentId: b, end_time: '11:30' } });
|
||||
expect((parseToolResult(result) as { assignment: { assignment_time: string; assignment_end_time: string } }).assignment)
|
||||
.toMatchObject({ assignment_time: '10:00', assignment_end_time: '11:30' });
|
||||
});
|
||||
|
||||
const order = (testDb.prepare('SELECT id FROM day_assignments WHERE day_id = ? ORDER BY order_index').all(day.id) as { id: number }[]).map(r => r.id);
|
||||
expect(order).toEqual([a, b]);
|
||||
expect(testDb.prepare('SELECT after_order_index FROM roadtrip_vias WHERE id = ?').get(via)).toEqual({ after_order_index: 0 });
|
||||
expect(broadcastMock.mock.calls.map(call => call[1])).toEqual(['assignment:updated']);
|
||||
});
|
||||
|
||||
it('returns error when assignment not found', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
@@ -99,13 +99,32 @@ describe('AssignmentOpsController (parity with the per-assignment op routes)', (
|
||||
|
||||
it('PUT /:id/time 404 missing, else updates', () => {
|
||||
expect(thrown(() => new AssignmentOpsController(svc({ getAssignmentForTrip: vi.fn().mockReturnValue(undefined) } as Partial<AssignmentsService>)).time(user, '5', '9', {}))).toEqual({ status: 404, body: { error: 'Assignment not found' } });
|
||||
const updateTime = vi.fn().mockReturnValue({ id: 9 }); const broadcast = vi.fn(); const reconcile = vi.fn();
|
||||
const updateTime = vi.fn().mockReturnValue({ assignment: { id: 9 }, reordered: null, vias: null }); const broadcast = vi.fn(); const reconcile = vi.fn();
|
||||
const s = svc({ getAssignmentForTrip: vi.fn().mockReturnValue({ id: 9 }), updateTime, broadcast, reconcile } as Partial<AssignmentsService>);
|
||||
expect(new AssignmentOpsController(s).time(user, '5', '9', { place_time: '10:00' }, 'sock')).toEqual({ assignment: { id: 9 } });
|
||||
expect(updateTime).toHaveBeenCalledWith('9', '10:00', undefined);
|
||||
// Nothing moved, so the row is all collaborators hear about.
|
||||
expect(broadcast.mock.calls).toEqual([['5', 'assignment:updated', { assignment: { id: 9 } }, 'sock']]);
|
||||
expect(reconcile).toHaveBeenCalledWith('5', 'sock');
|
||||
});
|
||||
|
||||
it('PUT /:id/time sends the whole day and the re-pinned vias to every socket when a start moved stops', () => {
|
||||
const reordered = { dayId: 3, orderedIds: [7, 9, 8] };
|
||||
const vias = { dayId: 3, vias: [{ id: 40, day_id: 3, after_order_index: 1, sequence: 0, lat: 1, lng: 2 }] };
|
||||
const updateTime = vi.fn().mockReturnValue({ assignment: { id: 9 }, reordered, vias }); const broadcast = vi.fn();
|
||||
const s = svc({ getAssignmentForTrip: vi.fn().mockReturnValue({ id: 9 }), updateTime, broadcast } as Partial<AssignmentsService>);
|
||||
|
||||
expect(new AssignmentOpsController(s).time(user, '5', '9', { place_time: '10:00' }, 'sock')).toEqual({ assignment: { id: 9 } });
|
||||
|
||||
expect(broadcast.mock.calls).toEqual([
|
||||
['5', 'assignment:updated', { assignment: { id: 9 } }, 'sock'],
|
||||
// The writer's socket too, so the order and the vias pinned to it arrive
|
||||
// together there as well, a replayed offline save included.
|
||||
['5', 'assignment:reordered', reordered, undefined],
|
||||
['5', 'roadtripVia:changed', vias, undefined],
|
||||
]);
|
||||
});
|
||||
|
||||
it('PUT /:id/notes 404 missing, else updates + broadcasts (#2163)', () => {
|
||||
expect(thrown(() => new AssignmentOpsController(svc({ getAssignmentForTrip: vi.fn().mockReturnValue(undefined) } as Partial<AssignmentsService>)).notes(user, '5', '9', { notes: 'n' }))).toEqual({ status: 404, body: { error: 'Assignment not found' } });
|
||||
const updateNotes = vi.fn().mockReturnValue({ id: 9, notes: 'Book the 10:00 entry' }); const broadcast = vi.fn(); const reconcile = vi.fn();
|
||||
|
||||
@@ -348,25 +348,56 @@ describe('getParticipants / setParticipants', () => {
|
||||
// ── updateTime / setLegTransportMode ──────────────────────────────────────────
|
||||
|
||||
describe('updateTime', () => {
|
||||
const order = (id: number) => (testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(id) as { order_index: number }).order_index;
|
||||
/** The day the way every reader lists it: by order_index, then insertion. */
|
||||
const dayOrder = (dayId: number) =>
|
||||
(testDb.prepare('SELECT id FROM day_assignments WHERE day_id = ? ORDER BY order_index, created_at, id').all(dayId) as { id: number }[]).map(r => r.id);
|
||||
|
||||
/**
|
||||
* A day of stops at the given order_index keys, each with its own start (null for
|
||||
* none). Times go in by SQL so the setup itself never sorts anything.
|
||||
*/
|
||||
function dayOf(stops: [string | null, number][]) {
|
||||
const { trip, day, place } = fixture();
|
||||
const ids = stops.map(([time, orderIndex]) => {
|
||||
const a = createDayAssignment(testDb, day.id, place.id, { order_index: orderIndex });
|
||||
if (time) testDb.prepare('UPDATE day_assignments SET assignment_time = ? WHERE id = ?').run(time, a.id);
|
||||
return a.id;
|
||||
});
|
||||
return { trip, day, ids };
|
||||
}
|
||||
|
||||
function addVia(dayId: number, afterOrderIndex: number, sequence = 0): number {
|
||||
return Number(testDb.prepare(
|
||||
'INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?, ?, ?, 48.1, 11.5)'
|
||||
).run(dayId, afterOrderIndex, sequence).lastInsertRowid);
|
||||
}
|
||||
const viaAnchors = (dayId: number) =>
|
||||
testDb.prepare('SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = ? ORDER BY id').all(dayId);
|
||||
|
||||
it('ASG-SVC-021: persists both times and re-selects the nested shape', () => {
|
||||
const { day, place } = fixture();
|
||||
const a = createDayAssignment(testDb, day.id, place.id);
|
||||
const updated = svc.updateTime(a.id, '09:00', '11:30');
|
||||
expect(updated).toMatchObject({ id: a.id, assignment_time: '09:00', assignment_end_time: '11:30' });
|
||||
const { assignment } = svc.updateTime(a.id, '09:00', '11:30');
|
||||
expect(assignment).toMatchObject({ id: a.id, assignment_time: '09:00', assignment_end_time: '11:30' });
|
||||
});
|
||||
|
||||
it('ASG-SVC-022: a truthy place_time auto-sorts the day chronologically (untimed keep relative position)', () => {
|
||||
const { day, place } = fixture();
|
||||
const a = createDayAssignment(testDb, day.id, place.id, { order_index: 0 });
|
||||
const b = createDayAssignment(testDb, day.id, place.id, { order_index: 1 });
|
||||
const c = createDayAssignment(testDb, day.id, place.id, { order_index: 2 });
|
||||
it('ASG-SVC-022: a start sorts the timed stops and leaves every untimed stop behind the one it followed', () => {
|
||||
// The tester's day: two stops without a time, then a start on the last one. It
|
||||
// used to jump to the top, because every untimed stop was appended after it.
|
||||
const untimedHead = dayOf([[null, 0], [null, 1], [null, 2]]);
|
||||
svc.updateTime(untimedHead.ids[2], '14:00', null);
|
||||
expect(dayOrder(untimedHead.day.id)).toEqual(untimedHead.ids);
|
||||
|
||||
svc.updateTime(c.id, '08:00', null);
|
||||
const order = (id: number) => (testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(id) as { order_index: number }).order_index;
|
||||
expect([order(c.id), order(a.id), order(b.id)]).toEqual([0, 1, 2]);
|
||||
const between = dayOf([['09:00', 0], [null, 1], [null, 2]]);
|
||||
svc.updateTime(between.ids[2], '14:00', null);
|
||||
expect(dayOrder(between.day.id)).toEqual(between.ids);
|
||||
|
||||
svc.updateTime(a.id, '07:00', null);
|
||||
expect([order(a.id), order(c.id), order(b.id)]).toEqual([0, 1, 2]);
|
||||
// Timed stops still keep time order among themselves. A has nothing timed in
|
||||
// front of it and stays first.
|
||||
const [a, b, c] = dayOf([[null, 0], ['15:00', 1], [null, 2]]).ids;
|
||||
svc.updateTime(c, '10:00', null);
|
||||
expect([order(a), order(c), order(b)]).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it('ASG-SVC-022b: a booked night sorts by its check-in, which is where its hour lives', () => {
|
||||
@@ -384,34 +415,33 @@ describe('updateTime', () => {
|
||||
|
||||
svc.updateTime(afternoon.id, '16:00', null);
|
||||
|
||||
const order = (id: number) => (testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(id) as { order_index: number }).order_index;
|
||||
expect(order(night.id)).toBeLessThan(order(afternoon.id));
|
||||
});
|
||||
|
||||
it('ASG-SVC-023: clearing with null persists but skips the auto-sort (falsy gate)', () => {
|
||||
const { day, place } = fixture();
|
||||
const a = createDayAssignment(testDb, day.id, place.id, { order_index: 0 });
|
||||
const b = createDayAssignment(testDb, day.id, place.id, { order_index: 1 });
|
||||
svc.updateTime(b.id, '06:00', null); // sorts b first
|
||||
const updated = svc.updateTime(b.id, null, null); // clear — no re-sort
|
||||
expect(updated!.assignment_time).toBeNull();
|
||||
const order = (id: number) => (testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(id) as { order_index: number }).order_index;
|
||||
expect(order(b.id)).toBe(0);
|
||||
expect(order(a.id)).toBe(1);
|
||||
const { day, ids: [a, b, c] } = dayOf([['09:00', 0], [null, 1], ['12:00', 2]]);
|
||||
svc.updateTime(b, '06:00', null); // sorts b first
|
||||
expect(dayOrder(day.id)).toEqual([b, a, c]);
|
||||
// Dragged back out of time order on purpose: a sort now would put b first again,
|
||||
// with c behind it.
|
||||
svc.reorderAssignments(day.id, [a, b, c]);
|
||||
|
||||
const { assignment } = svc.updateTime(c, null, null); // clear, no re-sort
|
||||
expect(assignment!.assignment_time).toBeNull();
|
||||
expect(dayOrder(day.id)).toEqual([a, b, c]);
|
||||
});
|
||||
|
||||
it('ASG-SVC-027: an empty-string time clears the override like null (and skips the auto-sort)', () => {
|
||||
const { day, place } = fixture();
|
||||
const a = createDayAssignment(testDb, day.id, place.id, { order_index: 0 });
|
||||
const b = createDayAssignment(testDb, day.id, place.id, { order_index: 1 });
|
||||
svc.updateTime(b.id, '06:00', '07:00'); // sorts b first
|
||||
const updated = svc.updateTime(b.id, '', '');
|
||||
expect(updated!.assignment_time).toBeNull();
|
||||
expect(updated!.assignment_end_time).toBeNull();
|
||||
expect(testDb.prepare('SELECT assignment_time FROM day_assignments WHERE id = ?').get(b.id)).toEqual({ assignment_time: null });
|
||||
const order = (id: number) => (testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(id) as { order_index: number }).order_index;
|
||||
expect(order(b.id)).toBe(0);
|
||||
expect(order(a.id)).toBe(1);
|
||||
const { day, ids: [a, b, c] } = dayOf([['09:00', 0], [null, 1], ['12:00', 2]]);
|
||||
svc.updateTime(b, '06:00', '07:00'); // sorts b first
|
||||
expect(dayOrder(day.id)).toEqual([b, a, c]);
|
||||
svc.reorderAssignments(day.id, [a, b, c]);
|
||||
|
||||
const { assignment } = svc.updateTime(c, '', '');
|
||||
expect(assignment!.assignment_time).toBeNull();
|
||||
expect(assignment!.assignment_end_time).toBeNull();
|
||||
expect(testDb.prepare('SELECT assignment_time FROM day_assignments WHERE id = ?').get(c)).toEqual({ assignment_time: null });
|
||||
expect(dayOrder(day.id)).toEqual([a, b, c]);
|
||||
});
|
||||
|
||||
it('ASG-SVC-024: a time without ":" sorts last among timed via the 99:99 sentinel', () => {
|
||||
@@ -420,10 +450,164 @@ describe('updateTime', () => {
|
||||
const b = createDayAssignment(testDb, day.id, place.id, { order_index: 1 });
|
||||
svc.updateTime(a.id, 'morning', null); // no colon → '99:99' in the sort
|
||||
svc.updateTime(b.id, '13:00', null);
|
||||
const order = (id: number) => (testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(id) as { order_index: number }).order_index;
|
||||
expect(order(b.id)).toBe(0);
|
||||
expect(order(a.id)).toBe(1);
|
||||
});
|
||||
|
||||
it('ASG-SVC-033: writes nothing but the time when the day is already in order', () => {
|
||||
// Keys with gaps, as a deleted stop leaves them. A day the start leaves in order
|
||||
// keeps them: nothing about it changed.
|
||||
const { day, ids: [a, b, c] } = dayOf([['09:00', 0], [null, 5], [null, 9]]);
|
||||
const via = addVia(day.id, 0);
|
||||
const changes = () => (testDb.prepare('SELECT total_changes() AS n').get() as { n: number }).n;
|
||||
const before = changes();
|
||||
|
||||
const update = svc.updateTime(c, '14:00', null);
|
||||
|
||||
expect(changes() - before).toBe(1);
|
||||
expect([order(a), order(b), order(c)]).toEqual([0, 5, 9]);
|
||||
expect(viaAnchors(day.id)).toEqual([{ id: via, after_order_index: 0, sequence: 0 }]);
|
||||
expect(update.reordered).toBeNull();
|
||||
expect(update.vias).toBeNull();
|
||||
});
|
||||
|
||||
it('ASG-SVC-034: numbers a reordered day from 0, the key each client gives a stop of the order it is sent', () => {
|
||||
// A gap, as a deleted stop leaves it. Kept, the writer would read 0, 4, 7 back and
|
||||
// everyone else would apply 0, 1, 2, and a note at 3 would sit behind a different
|
||||
// stop for each of them.
|
||||
const { day, ids: [a, b, c] } = dayOf([[null, 0], ['15:00', 4], [null, 7]]);
|
||||
const changes = () => (testDb.prepare('SELECT total_changes() AS n').get() as { n: number }).n;
|
||||
const before = changes();
|
||||
|
||||
const update = svc.updateTime(c, '10:00', null);
|
||||
|
||||
expect(update.reordered).toEqual({ dayId: day.id, orderedIds: [a, c, b] });
|
||||
expect(update.reordered!.orderedIds.map(order)).toEqual([0, 1, 2]);
|
||||
// The time, then c and b. A already holds 0 and is not written.
|
||||
expect(changes() - before).toBe(3);
|
||||
});
|
||||
|
||||
it('ASG-SVC-035: numbers a day with colliding keys from 0 again, since a shared key cannot hold an order', () => {
|
||||
// A move drops a stop in at order_index 0 next to the one already there.
|
||||
const { day, ids: [a, b, c] } = dayOf([[null, 0], ['15:00', 0], [null, 0]]);
|
||||
|
||||
svc.updateTime(c, '10:00', null);
|
||||
|
||||
expect(dayOrder(day.id)).toEqual([a, c, b]);
|
||||
expect([order(a), order(c), order(b)]).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it('ASG-SVC-036: keeps each drawn road behind the stop it was drawn after, counting located stops only', () => {
|
||||
// X has no coordinates, so the router never sees it and the vias do not count it.
|
||||
const { trip, day, ids: [a, x, b, c, d] } = dayOf([[null, 0], [null, 1], ['15:00', 2], [null, 3], [null, 4]]);
|
||||
const unmapped = createPlace(testDb, trip.id, { name: 'Somewhere unmapped' });
|
||||
testDb.prepare('UPDATE places SET lat = NULL, lng = NULL WHERE id = ?').run(unmapped.id);
|
||||
testDb.prepare('UPDATE day_assignments SET place_id = ? WHERE id = ?').run(unmapped.id, x);
|
||||
const afterA = addVia(day.id, 0);
|
||||
const afterB = addVia(day.id, 1);
|
||||
const afterBToo = addVia(day.id, 1, 1);
|
||||
addVia(day.id, 2); // after C
|
||||
|
||||
// D at 10:00 goes in front of B at 15:00, and C stays behind B: A, X, D, B, C.
|
||||
const update = svc.updateTime(d, '10:00', null);
|
||||
|
||||
expect(dayOrder(day.id)).toEqual([a, x, d, b, c]);
|
||||
// A keeps its leg, B's two points follow B to 2 in their order, and C, now last,
|
||||
// has no leg left to bend.
|
||||
expect(viaAnchors(day.id)).toEqual([
|
||||
{ id: afterA, after_order_index: 0, sequence: 0 },
|
||||
{ id: afterB, after_order_index: 2, sequence: 0 },
|
||||
{ id: afterBToo, after_order_index: 2, sequence: 1 },
|
||||
]);
|
||||
expect(update.reordered).toEqual({ dayId: day.id, orderedIds: [a, x, d, b, c] });
|
||||
expect(update.vias?.dayId).toBe(day.id);
|
||||
expect(update.vias?.vias.map(v => [v.id, v.after_order_index])).toEqual([[afterA, 0], [afterB, 2], [afterBToo, 2]]);
|
||||
});
|
||||
|
||||
it('ASG-SVC-037: reports no vias when the reorder leaves every one on its stop', () => {
|
||||
const { day, ids: [a, b, c] } = dayOf([[null, 0], ['15:00', 1], [null, 2]]);
|
||||
const via = addVia(day.id, 0);
|
||||
|
||||
const update = svc.updateTime(c, '10:00', null);
|
||||
|
||||
expect(update.reordered).toEqual({ dayId: day.id, orderedIds: [a, c, b] });
|
||||
expect(viaAnchors(day.id)).toEqual([{ id: via, after_order_index: 0, sequence: 0 }]);
|
||||
expect(update.vias).toBeNull();
|
||||
});
|
||||
|
||||
it('ASG-SVC-038: leaves every via alone when the sort moves only stops without coordinates', () => {
|
||||
// X has no coordinates. It goes between A and B, and the located stops stay A, B.
|
||||
const { trip, day, ids: [a, b, x] } = dayOf([['09:00', 0], ['12:00', 1], [null, 2]]);
|
||||
const unmapped = createPlace(testDb, trip.id, { name: 'Somewhere unmapped' });
|
||||
testDb.prepare('UPDATE places SET lat = NULL, lng = NULL WHERE id = ?').run(unmapped.id);
|
||||
testDb.prepare('UPDATE day_assignments SET place_id = ? WHERE id = ?').run(unmapped.id, x);
|
||||
const afterA = addVia(day.id, 0);
|
||||
// Behind the day's last stop: the drive into the next day on a trip with connected
|
||||
// days. It was deleted here, since a last stop has no leg of its own.
|
||||
const intoTomorrow = addVia(day.id, 1);
|
||||
|
||||
const update = svc.updateTime(x, '10:00', null);
|
||||
|
||||
expect(dayOrder(day.id)).toEqual([a, x, b]);
|
||||
expect(update.reordered).toEqual({ dayId: day.id, orderedIds: [a, x, b] });
|
||||
expect(viaAnchors(day.id)).toEqual([
|
||||
{ id: afterA, after_order_index: 0, sequence: 0 },
|
||||
{ id: intoTomorrow, after_order_index: 1, sequence: 0 },
|
||||
]);
|
||||
expect(update.vias).toBeNull();
|
||||
});
|
||||
|
||||
it('ASG-SVC-039: keeps the via behind the last stop when that stop is still last', () => {
|
||||
const { day, ids: [a, b, c, d] } = dayOf([[null, 0], ['15:00', 1], [null, 2], ['18:00', 3]]);
|
||||
const afterB = addVia(day.id, 1);
|
||||
const intoTomorrow = addVia(day.id, 3);
|
||||
|
||||
// C at 10:00 goes in front of B. D stays last, and so does the drive out of it.
|
||||
const update = svc.updateTime(c, '10:00', null);
|
||||
|
||||
expect(dayOrder(day.id)).toEqual([a, c, b, d]);
|
||||
expect(viaAnchors(day.id)).toEqual([
|
||||
{ id: afterB, after_order_index: 2, sequence: 0 },
|
||||
{ id: intoTomorrow, after_order_index: 3, sequence: 0 },
|
||||
]);
|
||||
expect(update.vias?.vias.map(v => [v.id, v.after_order_index])).toEqual([[afterB, 2], [intoTomorrow, 3]]);
|
||||
});
|
||||
|
||||
it('ASG-SVC-040: an End saved with the start as it stood leaves a day dragged out of time order alone', () => {
|
||||
// B belongs first by time, and the traveller put it second on purpose.
|
||||
const { day, ids: [a, b, c] } = dayOf([['14:00', 0], ['10:00', 1], ['16:00', 2]]);
|
||||
testDb.prepare("UPDATE day_assignments SET assignment_end_time = '11:00' WHERE id = ?").run(b);
|
||||
const afterA = addVia(day.id, 0);
|
||||
|
||||
// The stay dialog taking the End off, then the place form setting a new one.
|
||||
for (const end of [null, '12:00']) {
|
||||
const update = svc.updateTime(b, '10:00', end);
|
||||
expect(update.assignment).toMatchObject({ assignment_time: '10:00', assignment_end_time: end });
|
||||
expect(update.reordered).toBeNull();
|
||||
expect(update.vias).toBeNull();
|
||||
expect(dayOrder(day.id)).toEqual([a, b, c]);
|
||||
expect(viaAnchors(day.id)).toEqual([{ id: afterA, after_order_index: 0, sequence: 0 }]);
|
||||
}
|
||||
|
||||
// A start that moves still sorts, and the via follows A.
|
||||
const update = svc.updateTime(b, '09:00', '12:00');
|
||||
expect(dayOrder(day.id)).toEqual([b, a, c]);
|
||||
expect(update.reordered).toEqual({ dayId: day.id, orderedIds: [b, a, c] });
|
||||
expect(viaAnchors(day.id)).toEqual([{ id: afterA, after_order_index: 1, sequence: 0 }]);
|
||||
});
|
||||
|
||||
it('ASG-SVC-041: a visit given the start its place already had does not sort either', () => {
|
||||
// The place form fills Start from the place when the visit has none of its own.
|
||||
const { trip, day, ids: [a, b] } = dayOf([['14:00', 0], [null, 1]]);
|
||||
const timedPlace = createPlace(testDb, trip.id, { name: 'Opens at ten' });
|
||||
testDb.prepare("UPDATE places SET place_time = '10:00' WHERE id = ?").run(timedPlace.id);
|
||||
testDb.prepare('UPDATE day_assignments SET place_id = ? WHERE id = ?').run(timedPlace.id, b);
|
||||
|
||||
const update = svc.updateTime(b, '10:00', '11:00');
|
||||
|
||||
expect(update.reordered).toBeNull();
|
||||
expect(dayOrder(day.id)).toEqual([a, b]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateNotes (#2163)', () => {
|
||||
@@ -451,7 +635,9 @@ describe('updateNotes (#2163)', () => {
|
||||
svc.updateTime(a.id, '09:00', '10:00');
|
||||
const updated = svc.updateNotes(a.id, 'note');
|
||||
expect(updated).toMatchObject({ assignment_time: '09:00', assignment_end_time: '10:00' });
|
||||
expect((testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(a.id) as { order_index: number }).order_index).toBe(0);
|
||||
// 3, not 0: a start on a day already in order no longer renumbers it, so the key
|
||||
// this note edit must not touch is still the one the stop was created with.
|
||||
expect((testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(a.id) as { order_index: number }).order_index).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user