mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(roadtrip): show every change to a road trip live to everyone on it
Two people shaping one drive were each editing their own copy: a via dragged on one screen reached the other only on the next reload, and the route it changed looked settled until then. Every write in the domain now announces itself over the websocket the rest of the trip already uses — the five REST routes and the matching MCP tools alike, so an assistant moving a stop is as visible as a person doing it. Two new trip-scoped events carry it: roadtripVia:changed with the day's whole via list, and roadtripTrack:changed. The whole list rather than a delta, because a via is identified by its position in a chain and a delta protocol would need an ordering guarantee to be worth anything. The originating socket is excluded through the X-Socket-Id header the other domains already forward, so nobody watches their own change arrive twice.
This commit is contained in:
@@ -37,6 +37,8 @@ function svc(o: Partial<RoadtripService> = {}): RoadtripService {
|
||||
reanchor: vi.fn().mockReturnValue([VIA]),
|
||||
move: vi.fn().mockReturnValue(VIA),
|
||||
remove: vi.fn().mockReturnValue(true),
|
||||
// Every write announces the day's new shape to the trip's other clients.
|
||||
broadcast: vi.fn(),
|
||||
...o,
|
||||
} as unknown as RoadtripService;
|
||||
}
|
||||
@@ -188,4 +190,52 @@ describe('RoadtripController — writes', () => {
|
||||
expect(thrown(() => new RoadtripController(s).remove('7', '4', '999')))
|
||||
.toEqual({ status: 404, body: { error: 'Via not found' } });
|
||||
});
|
||||
|
||||
/**
|
||||
* Two people planning one road trip look at the same line on the same map, and a
|
||||
* reshaped drive moves every arrival time after it. These routes used to write in
|
||||
* silence, which left the other side reading a route nobody could see change.
|
||||
*/
|
||||
describe('telling the trip', () => {
|
||||
it('SRV-ROADTRIP-020: every write announces the new shape of the day', () => {
|
||||
const cases: [string, (c: RoadtripController) => unknown][] = [
|
||||
['create', c => c.create('7', '4', { after_order_index: 0, lat: 1, lng: 2 }, 'sock')],
|
||||
['createMany', c => c.createMany('7', '4', { vias: [{ after_order_index: 0, lat: 1, lng: 2 }] }, 'sock')],
|
||||
['reanchor', c => c.reanchor('7', '4', { vias: [] }, 'sock')],
|
||||
['update', c => c.update('7', '4', '9', { lat: 1, lng: 2 }, 'sock')],
|
||||
['remove', c => c.remove('7', '4', '9', 'sock')],
|
||||
];
|
||||
for (const [name, run] of cases) {
|
||||
const s = svc();
|
||||
run(new RoadtripController(s));
|
||||
expect(s.broadcast, name).toHaveBeenCalledWith(
|
||||
'7',
|
||||
'roadtripVia:changed',
|
||||
{ dayId: '4', vias: [VIA] },
|
||||
'sock',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('SRV-ROADTRIP-021: the day list is read back, not assembled from the write', () => {
|
||||
// A reanchor rewrites the whole set and a batch may clear legs before filling them,
|
||||
// so what the write returned is not what the day now holds.
|
||||
const s = svc({ reanchor: vi.fn().mockReturnValue([]) } as Partial<RoadtripService>);
|
||||
new RoadtripController(s).reanchor('7', '4', { vias: [] }, 'sock');
|
||||
expect(s.broadcast).toHaveBeenCalledWith('7', 'roadtripVia:changed', { dayId: '4', vias: [VIA] }, 'sock');
|
||||
});
|
||||
|
||||
it('SRV-ROADTRIP-022: laying a track down says which track the day now follows', () => {
|
||||
const track = { day_id: 4, place_id: 3, name: 'B96' };
|
||||
const s = svc({ tracksForTrip: vi.fn().mockReturnValue([track]) } as Partial<RoadtripService>);
|
||||
new RoadtripController(s).createMany('7', '4', { vias: [], track: { place_id: 3 } }, 'sock');
|
||||
expect(s.broadcast).toHaveBeenCalledWith('7', 'roadtripTrack:changed', { dayId: '4', track }, 'sock');
|
||||
});
|
||||
|
||||
it('SRV-ROADTRIP-023: a refused write announces nothing', () => {
|
||||
const s = svc({ remove: vi.fn().mockReturnValue(false) } as Partial<RoadtripService>);
|
||||
expect(() => new RoadtripController(s).remove('7', '4', '9', 'sock')).toThrow();
|
||||
expect(s.broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,7 +43,9 @@ function makeService() {
|
||||
run: (sql: string, ...params: unknown[]) => raw.prepare(sql).run(...params as never[]),
|
||||
transaction: <T>(fn: (conn: unknown) => T) => raw.transaction(() => fn(raw))(),
|
||||
};
|
||||
return new RoadtripService(db as never);
|
||||
// The realtime side is exercised through the controller and the MCP layer; here the
|
||||
// service is under test for what it writes, so a broadcast that goes nowhere is right.
|
||||
return new RoadtripService(db as never, { broadcast: () => {} } as never);
|
||||
}
|
||||
|
||||
describe('RoadtripService', () => {
|
||||
|
||||
Reference in New Issue
Block a user