mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
de86e6383a
PAD-06 and PAD-09 on screen. A paddle tour open in the planner or shown as recorded carries a checklist card: the obstacles of the data along its route in route order with the paddler's own portages and locks among them, each with its status, its paddling rule and the SUP advice at a lock, and the earlier decisions the route or the data no longer carries behind a fold. A row moves the camera onto the obstacle and the other parts of its lock and opens its card on the map, where the decision panel sits: four choices, the notices that stay after a choice (a ban, the SUP advice, a portage still to plan), the date of the last check on site, a note and who decided when. An object the map does not hold opens in a dialog instead. Deciding waits while the route has unsaved changes, and a stale or dirty list is dimmed, never hidden. The tours list, the desktop day plan and the phone day plan show the bench as one pill, the tour detail shows it with the data's date, and every paddle tour reaches the safety note from its footer. Nothing reads as safe where TREK knows nothing, and nothing blocks.
148 lines
4.9 KiB
TypeScript
148 lines
4.9 KiB
TypeScript
/**
|
|
* mutationQueue replay of a paddle tour's decisions made offline (PAD-06):
|
|
* the server's checklist replaces the local one and provenance stays, a
|
|
* checklist this build cannot read never stalls the queue, a refused body is
|
|
* parked as failed and an obstacle data outage waits for a retry.
|
|
*/
|
|
import type { TourListItem, TourProvenance } from '@trek/shared';
|
|
import 'fake-indexeddb/auto';
|
|
import { http, HttpResponse } from 'msw';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { clearAll, offlineDb } from '../../../src/db/offlineDb';
|
|
import { tourRepo } from '../../../src/repo/tourRepo';
|
|
import { setAuthed } from '../../../src/sync/authGate';
|
|
import { mutationQueue } from '../../../src/sync/mutationQueue';
|
|
import { server } from '../../helpers/msw/server';
|
|
import { obstacleCheck, obstacleEntry, paddleBench, ROUTE_BASIS, SOURCE_BASIS } from '../../helpers/paddleCheck';
|
|
|
|
function setOnline(v: boolean): void {
|
|
Object.defineProperty(navigator, 'onLine', { value: v, writable: true, configurable: true });
|
|
}
|
|
|
|
function paddleTour(overrides: Partial<TourListItem> = {}): TourListItem {
|
|
return {
|
|
place_id: 40,
|
|
name: 'Regen',
|
|
tour_type: 'kayak',
|
|
distance: 8,
|
|
elevation_gain: null,
|
|
elevation_loss: null,
|
|
duration: 120,
|
|
difficulty: null,
|
|
wanderer_ref: null,
|
|
match_confidence: 1,
|
|
max_hiking_difficulty: null,
|
|
planned: false,
|
|
caution: false,
|
|
has_waypoints: true,
|
|
paddle_check: paddleBench(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const waypoints = [
|
|
{ lat: 49.2, lng: 12, role: 'start' as const, sequence: 0 },
|
|
{ lat: 49.2, lng: 12.01, role: 'end' as const, sequence: 1 },
|
|
];
|
|
const provenance: TourProvenance = {
|
|
origin: 'trek',
|
|
createdAt: null,
|
|
source: { kind: 'trek' },
|
|
derivedFrom: null,
|
|
plannedOver: false,
|
|
editedLocally: null,
|
|
};
|
|
const decide = {
|
|
ops: [
|
|
{
|
|
key: 'node/1',
|
|
op: 'decide' as const,
|
|
decision: 'pass' as const,
|
|
note: null,
|
|
basis: { route: ROUTE_BASIS, source: SOURCE_BASIS },
|
|
},
|
|
],
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
await clearAll();
|
|
mutationQueue._resetFlushing();
|
|
setAuthed(true);
|
|
setOnline(false);
|
|
await offlineDb.tours.put({ ...paddleTour(), trip_id: 3, waypoints, provenance, obstacle_check: obstacleCheck() });
|
|
vi.spyOn(mutationQueue, 'sendSoon').mockImplementation(() => undefined);
|
|
await tourRepo.saveObstacleReviews(3, 40, decide, 4);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
setAuthed(false);
|
|
setOnline(true);
|
|
});
|
|
|
|
describe('mutationQueue.flush: obstacle decisions', () => {
|
|
it('FE-SYNC-TOUR-010: a replayed decision caches the checklist the server answered and keeps provenance', async () => {
|
|
const answered = obstacleCheck({ revision: 'rev-2', entries: [obstacleEntry({ status: 'decided' })] });
|
|
let key: string | null = null;
|
|
server.use(
|
|
http.put('/api/trips/3/tours/40/obstacle-reviews', ({ request }) => {
|
|
key = request.headers.get('X-Idempotency-Key');
|
|
return HttpResponse.json({
|
|
tour: paddleTour({ paddle_check: paddleBench({ revision: 'rev-2' }) }),
|
|
waypoints,
|
|
obstacle_check: answered,
|
|
});
|
|
})
|
|
);
|
|
const [queued] = await offlineDb.mutationQueue.toArray();
|
|
setOnline(true);
|
|
|
|
await mutationQueue.flush();
|
|
|
|
expect(key).toBe(queued?.id);
|
|
expect(await offlineDb.mutationQueue.count()).toBe(0);
|
|
expect(await offlineDb.tours.get(40)).toMatchObject({
|
|
obstacle_check: answered,
|
|
provenance,
|
|
paddle_check: { revision: 'rev-2' },
|
|
});
|
|
});
|
|
|
|
it('FE-SYNC-TOUR-011: a checklist the answer carries that this build cannot read does not stall the queue', async () => {
|
|
server.use(
|
|
http.put('/api/trips/3/tours/40/obstacle-reviews', () =>
|
|
HttpResponse.json({ tour: paddleTour(), waypoints, obstacle_check: { entries: 'not a list' } })
|
|
)
|
|
);
|
|
const local = (await offlineDb.tours.get(40))?.obstacle_check;
|
|
setOnline(true);
|
|
|
|
await mutationQueue.flush();
|
|
|
|
expect(await offlineDb.mutationQueue.count()).toBe(0);
|
|
expect((await offlineDb.tours.get(40))?.obstacle_check).toEqual(local);
|
|
});
|
|
|
|
it('FE-SYNC-TOUR-012: a refused body is parked as failed, an obstacle data outage waits for a retry', async () => {
|
|
server.use(
|
|
http.put('/api/trips/3/tours/40/obstacle-reviews', () =>
|
|
HttpResponse.json({ error: 'Obstacle data unavailable' }, { status: 503 })
|
|
)
|
|
);
|
|
setOnline(true);
|
|
await mutationQueue.flush();
|
|
expect((await offlineDb.mutationQueue.toArray())[0]).toMatchObject({ status: 'pending' });
|
|
|
|
mutationQueue._resetFlushing();
|
|
await offlineDb.mutationQueue.toCollection().modify({ retryAfter: undefined });
|
|
server.use(
|
|
http.put('/api/trips/3/tours/40/obstacle-reviews', () =>
|
|
HttpResponse.json({ error: 'Only a paddle tour has an obstacle checklist' }, { status: 400 })
|
|
)
|
|
);
|
|
await mutationQueue.flush();
|
|
expect((await offlineDb.mutationQueue.toArray())[0]).toMatchObject({ status: 'failed' });
|
|
});
|
|
});
|