anthodev/lapin-futepublic Fork
⑂ 6ae56e9
Commits
⬇ Clone ▾
git clone https://git.rickub.com/anthodev/lapin-fute.git
git clone ssh://git@rickub.com/anthodev/lapin-fute.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

fix(traffic): show the latest active incident and exclude works

Cedric ANTHONY committed 2026-09-19T20:32:48+02:00 Browse files
6ae56e9 parent: 102d719
modified README.md +1 -4
@@ -41,12 +41,9 @@ Lapin Futé puts Île-de-France public-transport arrivals on a Pebble watch. The
4141
4242 ## Features
4343
44-> [!NOTE]
45-> Departure-to-arrival favorites, the uncertainty flag, and the journey-pattern catalog described below are implemented in this source tree but not published yet. They land together: a rebuilt station catalog, the matching settings page, and a rebuilt watch app are all required. The new on-watch rendering is not natively verified yet; the screenshots above show the currently published release.
46-
4744 - **Six favorite journeys, five transport modes.** A favorite is a departure stop, a line, and a reachable arrival stop, including intermediate stops that no service terminates at. Journeys stay on one line, without connections, across Métro, RER, Transilien, Tram, and Bus. Favorites are renamed and reordered on the phone page, which shows official line badges and colors.
4845 - **Arrivals at a glance.** The favorites list shows the next arrival for each journey; Select opens its arrival board. Confirmed full-line and short-turn services are combined chronologically. Departures known not to serve the journey are excluded; uncertain departures follow the confirmed ones, marked with `?` and retaining their real times. Conflicting terminal hints can still confirm a journey when every candidate route serves its arrival. Countdowns keep ticking on the watch between refreshes.
49-- **Traffic view.** Select again on an arrival board to page through the current service messages for that line.
46+- **Traffic view.** Select again on an arrival board to read the most recently updated active disruption for that line, using PRIM's `lastUpdate` rather than severity. Works are excluded, including works already under way; future and expired disruptions are excluded as well. If the selected English message lacks a usable title or body, the app uses the complete French message for that same incident. This may require one additional French request when no fresh French response is already available.
5047 - **Refresh when needed.** Open the app, switch favorite, choose Refresh all, or hold Select to request arrivals. Recent results are reused for 60 seconds; after a failed refresh, the last complete result remains visibly stale for at most 15 minutes. There is no periodic network polling.
5148 - **Clear data states.** The app distinguishes real-time arrivals, mixed real-time and scheduled results, updates in progress, missing cached data, and trips whose route to your arrival is unconfirmed (`?`).
5249 - **English and French.** The watch interface and the settings page are both bilingual.
@@ -41,12 +41,9 @@ Lapin Futé puts Île-de-France public-transport arrivals on a Pebble watch. The
41 41
42 ## Features42 ## Features
43 43
44-> [!NOTE]
45-> Departure-to-arrival favorites, the uncertainty flag, and the journey-pattern catalog described below are implemented in this source tree but not published yet. They land together: a rebuilt station catalog, the matching settings page, and a rebuilt watch app are all required. The new on-watch rendering is not natively verified yet; the screenshots above show the currently published release.
46-
47 - **Six favorite journeys, five transport modes.** A favorite is a departure stop, a line, and a reachable arrival stop, including intermediate stops that no service terminates at. Journeys stay on one line, without connections, across Métro, RER, Transilien, Tram, and Bus. Favorites are renamed and reordered on the phone page, which shows official line badges and colors.44 - **Six favorite journeys, five transport modes.** A favorite is a departure stop, a line, and a reachable arrival stop, including intermediate stops that no service terminates at. Journeys stay on one line, without connections, across Métro, RER, Transilien, Tram, and Bus. Favorites are renamed and reordered on the phone page, which shows official line badges and colors.
48 - **Arrivals at a glance.** The favorites list shows the next arrival for each journey; Select opens its arrival board. Confirmed full-line and short-turn services are combined chronologically. Departures known not to serve the journey are excluded; uncertain departures follow the confirmed ones, marked with `?` and retaining their real times. Conflicting terminal hints can still confirm a journey when every candidate route serves its arrival. Countdowns keep ticking on the watch between refreshes.45 - **Arrivals at a glance.** The favorites list shows the next arrival for each journey; Select opens its arrival board. Confirmed full-line and short-turn services are combined chronologically. Departures known not to serve the journey are excluded; uncertain departures follow the confirmed ones, marked with `?` and retaining their real times. Conflicting terminal hints can still confirm a journey when every candidate route serves its arrival. Countdowns keep ticking on the watch between refreshes.
49-- **Traffic view.** Select again on an arrival board to page through the current service messages for that line.46+- **Traffic view.** Select again on an arrival board to read the most recently updated active disruption for that line, using PRIM's `lastUpdate` rather than severity. Works are excluded, including works already under way; future and expired disruptions are excluded as well. If the selected English message lacks a usable title or body, the app uses the complete French message for that same incident. This may require one additional French request when no fresh French response is already available.
50 - **Refresh when needed.** Open the app, switch favorite, choose Refresh all, or hold Select to request arrivals. Recent results are reused for 60 seconds; after a failed refresh, the last complete result remains visibly stale for at most 15 minutes. There is no periodic network polling.47 - **Refresh when needed.** Open the app, switch favorite, choose Refresh all, or hold Select to request arrivals. Recent results are reused for 60 seconds; after a failed refresh, the last complete result remains visibly stale for at most 15 minutes. There is no periodic network polling.
51 - **Clear data states.** The app distinguishes real-time arrivals, mixed real-time and scheduled results, updates in progress, missing cached data, and trips whose route to your arrival is unconfirmed (`?`).48 - **Clear data states.** The app distinguishes real-time arrivals, mixed real-time and scheduled results, updates in progress, missing cached data, and trips whose route to your arrival is unconfirmed (`?`).
52 - **English and French.** The watch interface and the settings page are both bilingual.49 - **English and French.** The watch interface and the settings page are both bilingual.
modified packages/companion/src/prim-client.js +73 -40
@@ -131,6 +131,7 @@ function createPrimClient(options) {
131131 event.timer = null;
132132 event.complete = null;
133133 event.outcome = null;
134+ event.resolve = null;
134135 onCancel = event.onCancel;
135136 event.onCancel = null;
136137 if (onCancel) onCancel();
@@ -142,6 +143,7 @@ function createPrimClient(options) {
142143 if (event.settled || event.scheduled) return;
143144 event.scheduled = true;
144145 event.onCancel = null;
146+ event.resolve = null;
145147 event.outcome = outcome;
146148 event.timer = later(function () {
147149 var complete;
@@ -265,7 +267,7 @@ function createPrimClient(options) {
265267 xhr.responseType = "arraybuffer";
266268 xhr.timeout = contracts.LIMITS.httpTimeoutMs;
267269 xhr.setRequestHeader("Accept", "application/json");
268- if (request.language !== null) xhr.setRequestHeader("Accept-Language", request.language);
270+ if (request.language === "en") xhr.setRequestHeader("Accept-Language", "en");
269271 xhr.setRequestHeader("apikey", request.apiKey);
270272 request.apiKey = null;
271273 xhr.onload = function () { receive(request); };
@@ -342,7 +344,6 @@ function createPrimClient(options) {
342344 var receivedAt = now();
343345 var index;
344346 var event;
345- var result;
346347 removeFlight(flight);
347348 flight.subscribers = [];
348349 flight.handle = null;
@@ -355,61 +356,34 @@ function createPrimClient(options) {
355356 flight.apiKey = null;
356357 for (index = 0; index < subscribers.length; index += 1) {
357358 event = subscribers[index];
358- if (outcome.status === "AVAILABLE") {
359- try {
360- result = { status: "AVAILABLE", data: trafficParser.trafficForLine(outcome.data.envelope,
361- event.lineId, receivedAt, receivedAt) };
362- } catch (ignored) { result = unavailable("INVALID_RESPONSE", receivedAt); }
363- } else result = unavailable(outcome.error.code, outcome.error.occurredAt * 1000, outcome.error.retryAfterSeconds);
364- deliver(event, result);
359+ event.onCancel = null;
360+ event.resolve(outcome);
365361 }
366362 }
367363
368- function traffic(request, complete) {
369- var event = subscriber(complete);
370- var lineId;
371- var cached;
364+ function subscribeTraffic(event, language, apiKey, resolve) {
372365 var startedAt = now();
366+ var cached = trafficCache[language];
373367 var age;
374368 var flight;
375369 var index;
376- if (!request || typeof request.lineRef !== "string") {
377- deliver(event, unavailable("INVALID_SERVICE", startedAt));
378- return event.handle;
379- }
380- lineId = trafficParser.canonicalCatalogLineRef(request.lineRef);
381- if (typeof lineId === "undefined") {
382- deliver(event, unavailable("INVALID_SERVICE", startedAt));
383- return event.handle;
384- }
385- if (request.language !== "fr" && request.language !== "en") {
386- deliver(event, unavailable("INVALID_RESPONSE", startedAt));
387- return event.handle;
388- }
389- if (!contracts.isPersonalApiKey(request.apiKey) || containsCredential(TRAFFIC_URL, request.apiKey)) {
390- deliver(event, unavailable("API_KEY_INVALID", startedAt));
391- return event.handle;
392- }
393- cached = trafficCache[request.language];
370+ event.resolve = resolve;
394371 if (cached !== null) {
395372 age = startedAt - cached.storedAt;
396- if (cached.apiKey === request.apiKey && age >= 0 && age < contracts.CACHE_FRESH_SECONDS * 1000) {
397- try {
398- deliver(event, { status: "AVAILABLE", data: trafficParser.trafficForLine(cached.envelope, lineId, startedAt, cached.storedAt) });
399- } catch (ignored) { deliver(event, unavailable("INVALID_RESPONSE", startedAt)); }
400- return event.handle;
373+ if (cached.apiKey === apiKey && age >= 0 && age < contracts.CACHE_FRESH_SECONDS * 1000) {
374+ resolve({ status: "AVAILABLE", data: cached });
375+ return;
401376 }
402- trafficCache[request.language] = null;
377+ trafficCache[language] = null;
403378 }
404- event.lineId = lineId;
405379 for (index = 0; index < trafficFlights.length; index += 1) {
406- if (trafficFlights[index].language === request.language && trafficFlights[index].apiKey === request.apiKey) {
380+ if (trafficFlights[index].language === language && trafficFlights[index].apiKey === apiKey) {
407381 flight = trafficFlights[index];
408382 break;
409383 }
410384 }
411385 if (typeof flight === "undefined") {
412- flight = { language: request.language, apiKey: request.apiKey, subscribers: [], handle: null };
386+ flight = { language: language, apiKey: apiKey, subscribers: [], handle: null };
413387 trafficFlights.push(flight);
414388 }
415389 flight.subscribers.push(event);
@@ -428,6 +402,65 @@ function createPrimClient(options) {
428402 return { storedAt: receivedAt, envelope: trafficParser.normalizePrimTrafficResponse(payload, flight.apiKey) };
429403 }, function (outcome) { finishTraffic(flight, outcome); });
430404 }
405+ }
406+
407+ function traffic(request, complete) {
408+ var event = subscriber(complete);
409+ var startedAt = now();
410+ var lineId = request ? trafficParser.canonicalCatalogLineRef(request.lineRef) : undefined;
411+ var apiKey;
412+ var language;
413+ if (typeof lineId === "undefined") {
414+ deliver(event, unavailable("INVALID_SERVICE", startedAt));
415+ return event.handle;
416+ }
417+ language = request.language;
418+ if (language !== "fr" && language !== "en") {
419+ deliver(event, unavailable("INVALID_RESPONSE", startedAt));
420+ return event.handle;
421+ }
422+ apiKey = request.apiKey;
423+ if (!contracts.isPersonalApiKey(apiKey) || containsCredential(TRAFFIC_URL, apiKey)) {
424+ deliver(event, unavailable("API_KEY_INVALID", startedAt));
425+ return event.handle;
426+ }
427+ subscribeTraffic(event, language, apiKey, function (outcome) {
428+ var evaluatedAt = now();
429+ var snapshot;
430+ var result;
431+ if (outcome.status !== "AVAILABLE") {
432+ deliver(event, unavailable(outcome.error.code, outcome.error.occurredAt * 1000, outcome.error.retryAfterSeconds));
433+ return;
434+ }
435+ snapshot = outcome.data;
436+ try {
437+ result = trafficParser.trafficForLine(snapshot.envelope, lineId, evaluatedAt, snapshot.storedAt);
438+ } catch (ignored) {
439+ deliver(event, unavailable("INVALID_RESPONSE", evaluatedAt));
440+ return;
441+ }
442+ if (language !== "en" || result.state !== "UNKNOWN") {
443+ deliver(event, { status: "AVAILABLE", data: result });
444+ return;
445+ }
446+ subscribeTraffic(event, "fr", apiKey, function (french) {
447+ var completedAt = now();
448+ var age = completedAt - snapshot.storedAt;
449+ if (french.status !== "AVAILABLE") {
450+ deliver(event, unavailable(french.error.code, french.error.occurredAt * 1000, french.error.retryAfterSeconds));
451+ return;
452+ }
453+ if (age < 0 || age >= contracts.CACHE_FRESH_SECONDS * 1000) {
454+ deliver(event, unavailable("INVALID_RESPONSE", completedAt));
455+ return;
456+ }
457+ try {
458+ result = trafficParser.trafficForLine(snapshot.envelope, lineId, evaluatedAt,
459+ Math.min(snapshot.storedAt, french.data.storedAt), french.data.envelope, completedAt);
460+ deliver(event, { status: "AVAILABLE", data: result });
461+ } catch (ignored) { deliver(event, unavailable("INVALID_RESPONSE", completedAt)); }
462+ });
463+ });
431464 return event.handle;
432465 }
433466
@@ -131,6 +131,7 @@ function createPrimClient(options) {
131 event.timer = null;131 event.timer = null;
132 event.complete = null;132 event.complete = null;
133 event.outcome = null;133 event.outcome = null;
134+ event.resolve = null;
134 onCancel = event.onCancel;135 onCancel = event.onCancel;
135 event.onCancel = null;136 event.onCancel = null;
136 if (onCancel) onCancel();137 if (onCancel) onCancel();
@@ -142,6 +143,7 @@ function createPrimClient(options) {
142 if (event.settled || event.scheduled) return;143 if (event.settled || event.scheduled) return;
143 event.scheduled = true;144 event.scheduled = true;
144 event.onCancel = null;145 event.onCancel = null;
146+ event.resolve = null;
145 event.outcome = outcome;147 event.outcome = outcome;
146 event.timer = later(function () {148 event.timer = later(function () {
147 var complete;149 var complete;
@@ -265,7 +267,7 @@ function createPrimClient(options) {
265 xhr.responseType = "arraybuffer";267 xhr.responseType = "arraybuffer";
266 xhr.timeout = contracts.LIMITS.httpTimeoutMs;268 xhr.timeout = contracts.LIMITS.httpTimeoutMs;
267 xhr.setRequestHeader("Accept", "application/json");269 xhr.setRequestHeader("Accept", "application/json");
268- if (request.language !== null) xhr.setRequestHeader("Accept-Language", request.language);270+ if (request.language === "en") xhr.setRequestHeader("Accept-Language", "en");
269 xhr.setRequestHeader("apikey", request.apiKey);271 xhr.setRequestHeader("apikey", request.apiKey);
270 request.apiKey = null;272 request.apiKey = null;
271 xhr.onload = function () { receive(request); };273 xhr.onload = function () { receive(request); };
@@ -342,7 +344,6 @@ function createPrimClient(options) {
342 var receivedAt = now();344 var receivedAt = now();
343 var index;345 var index;
344 var event;346 var event;
345- var result;
346 removeFlight(flight);347 removeFlight(flight);
347 flight.subscribers = [];348 flight.subscribers = [];
348 flight.handle = null;349 flight.handle = null;
@@ -355,61 +356,34 @@ function createPrimClient(options) {
355 flight.apiKey = null;356 flight.apiKey = null;
356 for (index = 0; index < subscribers.length; index += 1) {357 for (index = 0; index < subscribers.length; index += 1) {
357 event = subscribers[index];358 event = subscribers[index];
358- if (outcome.status === "AVAILABLE") {359+ event.onCancel = null;
359- try {360+ event.resolve(outcome);
360- result = { status: "AVAILABLE", data: trafficParser.trafficForLine(outcome.data.envelope,
361- event.lineId, receivedAt, receivedAt) };
362- } catch (ignored) { result = unavailable("INVALID_RESPONSE", receivedAt); }
363- } else result = unavailable(outcome.error.code, outcome.error.occurredAt * 1000, outcome.error.retryAfterSeconds);
364- deliver(event, result);
365 }361 }
366 }362 }
367 363
368- function traffic(request, complete) {364+ function subscribeTraffic(event, language, apiKey, resolve) {
369- var event = subscriber(complete);
370- var lineId;
371- var cached;
372 var startedAt = now();365 var startedAt = now();
366+ var cached = trafficCache[language];
373 var age;367 var age;
374 var flight;368 var flight;
375 var index;369 var index;
376- if (!request || typeof request.lineRef !== "string") {370+ event.resolve = resolve;
377- deliver(event, unavailable("INVALID_SERVICE", startedAt));
378- return event.handle;
379- }
380- lineId = trafficParser.canonicalCatalogLineRef(request.lineRef);
381- if (typeof lineId === "undefined") {
382- deliver(event, unavailable("INVALID_SERVICE", startedAt));
383- return event.handle;
384- }
385- if (request.language !== "fr" && request.language !== "en") {
386- deliver(event, unavailable("INVALID_RESPONSE", startedAt));
387- return event.handle;
388- }
389- if (!contracts.isPersonalApiKey(request.apiKey) || containsCredential(TRAFFIC_URL, request.apiKey)) {
390- deliver(event, unavailable("API_KEY_INVALID", startedAt));
391- return event.handle;
392- }
393- cached = trafficCache[request.language];
394 if (cached !== null) {371 if (cached !== null) {
395 age = startedAt - cached.storedAt;372 age = startedAt - cached.storedAt;
396- if (cached.apiKey === request.apiKey && age >= 0 && age < contracts.CACHE_FRESH_SECONDS * 1000) {373+ if (cached.apiKey === apiKey && age >= 0 && age < contracts.CACHE_FRESH_SECONDS * 1000) {
397- try {374+ resolve({ status: "AVAILABLE", data: cached });
398- deliver(event, { status: "AVAILABLE", data: trafficParser.trafficForLine(cached.envelope, lineId, startedAt, cached.storedAt) });375+ return;
399- } catch (ignored) { deliver(event, unavailable("INVALID_RESPONSE", startedAt)); }
400- return event.handle;
401 }376 }
402- trafficCache[request.language] = null;377+ trafficCache[language] = null;
403 }378 }
404- event.lineId = lineId;
405 for (index = 0; index < trafficFlights.length; index += 1) {379 for (index = 0; index < trafficFlights.length; index += 1) {
406- if (trafficFlights[index].language === request.language && trafficFlights[index].apiKey === request.apiKey) {380+ if (trafficFlights[index].language === language && trafficFlights[index].apiKey === apiKey) {
407 flight = trafficFlights[index];381 flight = trafficFlights[index];
408 break;382 break;
409 }383 }
410 }384 }
411 if (typeof flight === "undefined") {385 if (typeof flight === "undefined") {
412- flight = { language: request.language, apiKey: request.apiKey, subscribers: [], handle: null };386+ flight = { language: language, apiKey: apiKey, subscribers: [], handle: null };
413 trafficFlights.push(flight);387 trafficFlights.push(flight);
414 }388 }
415 flight.subscribers.push(event);389 flight.subscribers.push(event);
@@ -428,6 +402,65 @@ function createPrimClient(options) {
428 return { storedAt: receivedAt, envelope: trafficParser.normalizePrimTrafficResponse(payload, flight.apiKey) };402 return { storedAt: receivedAt, envelope: trafficParser.normalizePrimTrafficResponse(payload, flight.apiKey) };
429 }, function (outcome) { finishTraffic(flight, outcome); });403 }, function (outcome) { finishTraffic(flight, outcome); });
430 }404 }
405+ }
406+
407+ function traffic(request, complete) {
408+ var event = subscriber(complete);
409+ var startedAt = now();
410+ var lineId = request ? trafficParser.canonicalCatalogLineRef(request.lineRef) : undefined;
411+ var apiKey;
412+ var language;
413+ if (typeof lineId === "undefined") {
414+ deliver(event, unavailable("INVALID_SERVICE", startedAt));
415+ return event.handle;
416+ }
417+ language = request.language;
418+ if (language !== "fr" && language !== "en") {
419+ deliver(event, unavailable("INVALID_RESPONSE", startedAt));
420+ return event.handle;
421+ }
422+ apiKey = request.apiKey;
423+ if (!contracts.isPersonalApiKey(apiKey) || containsCredential(TRAFFIC_URL, apiKey)) {
424+ deliver(event, unavailable("API_KEY_INVALID", startedAt));
425+ return event.handle;
426+ }
427+ subscribeTraffic(event, language, apiKey, function (outcome) {
428+ var evaluatedAt = now();
429+ var snapshot;
430+ var result;
431+ if (outcome.status !== "AVAILABLE") {
432+ deliver(event, unavailable(outcome.error.code, outcome.error.occurredAt * 1000, outcome.error.retryAfterSeconds));
433+ return;
434+ }
435+ snapshot = outcome.data;
436+ try {
437+ result = trafficParser.trafficForLine(snapshot.envelope, lineId, evaluatedAt, snapshot.storedAt);
438+ } catch (ignored) {
439+ deliver(event, unavailable("INVALID_RESPONSE", evaluatedAt));
440+ return;
441+ }
442+ if (language !== "en" || result.state !== "UNKNOWN") {
443+ deliver(event, { status: "AVAILABLE", data: result });
444+ return;
445+ }
446+ subscribeTraffic(event, "fr", apiKey, function (french) {
447+ var completedAt = now();
448+ var age = completedAt - snapshot.storedAt;
449+ if (french.status !== "AVAILABLE") {
450+ deliver(event, unavailable(french.error.code, french.error.occurredAt * 1000, french.error.retryAfterSeconds));
451+ return;
452+ }
453+ if (age < 0 || age >= contracts.CACHE_FRESH_SECONDS * 1000) {
454+ deliver(event, unavailable("INVALID_RESPONSE", completedAt));
455+ return;
456+ }
457+ try {
458+ result = trafficParser.trafficForLine(snapshot.envelope, lineId, evaluatedAt,
459+ Math.min(snapshot.storedAt, french.data.storedAt), french.data.envelope, completedAt);
460+ deliver(event, { status: "AVAILABLE", data: result });
461+ } catch (ignored) { deliver(event, unavailable("INVALID_RESPONSE", completedAt)); }
462+ });
463+ });
431 return event.handle;464 return event.handle;
432 }465 }
433 466
modified packages/companion/src/prim-traffic.js +29 -18
@@ -216,14 +216,16 @@ function normalizePrimTrafficResponse(value, apiKey) {
216216 if (begin > end) fail();
217217 periods.push(Object.freeze({ begin: begin, end: end }));
218218 }
219- if (typeof candidate.lastUpdate !== "undefined") localTimestamp(candidate.lastUpdate);
220219 if (typeof candidate.title !== "undefined" && typeof candidate.title !== "string") fail();
221220 if (typeof candidate.message !== "undefined" && typeof candidate.message !== "string") fail();
221+ if (typeof candidate.cause !== "undefined" && typeof candidate.cause !== "string") fail();
222222 title = typeof candidate.title === "undefined" ? undefined
223223 : plainBoundedText(candidate.title, contracts.LIMITS.trafficTitleUtf8Bytes, variants);
224224 text = typeof candidate.message === "undefined" ? undefined
225225 : plainBoundedText(candidate.message, contracts.LIMITS.trafficTextUtf8Bytes, variants);
226226 disruption = { id: candidate.id, severity: parseSeverity(candidate.severity), periods: Object.freeze(periods) };
227+ if (typeof candidate.cause !== "undefined") disruption.cause = candidate.cause;
228+ if (typeof candidate.lastUpdate !== "undefined") disruption.lastUpdate = localTimestamp(candidate.lastUpdate);
227229 if (typeof title !== "undefined") disruption.title = title;
228230 if (typeof text !== "undefined") disruption.text = text;
229231 disruptions[candidate.id] = Object.freeze(disruption);
@@ -272,13 +274,15 @@ function active(disruption, localNow) {
272274 return false;
273275 }
274276
275-function trafficForLine(envelope, lineId, evaluatedAtMilliseconds, checkedAtMilliseconds) {
277+function trafficForLine(envelope, lineId, evaluatedAtMilliseconds, checkedAtMilliseconds, frenchEnvelope, fallbackAtMilliseconds) {
276278 var localNow = currentParisTimestamp(evaluatedAtMilliseconds);
277279 var line = envelope.lines[lineId];
278280 var best;
279- var bestPriority = 0;
281+ var details;
282+ var fallbackNow;
280283 var disruption;
281- var priority;
284+ var updatedAt;
285+ var bestUpdatedAt;
282286 var index;
283287 var result = {
284288 schemaVersion: contracts.SCHEMA_VERSION,
@@ -294,24 +298,31 @@ function trafficForLine(envelope, lineId, evaluatedAtMilliseconds, checkedAtMill
294298 }
295299 for (index = 0; index < line.length; index += 1) {
296300 disruption = envelope.disruptions[line[index]];
297- if (!active(disruption, localNow) || disruption.severity === "INFORMATION") continue;
298- if (typeof disruption.title === "undefined" || typeof disruption.text === "undefined") priority = 2;
299- else priority = disruption.severity === "BLOQUANTE" ? 3 : 1;
300- if (typeof best === "undefined" || priority > bestPriority
301- || (priority === bestPriority && disruption.id < best.id)) {
301+ // Works never surface as traffic, including works already under way.
302+ if (disruption.cause === "TRAVAUX" || !active(disruption, localNow)
303+ || disruption.severity === "INFORMATION") continue;
304+ updatedAt = disruption.lastUpdate || "";
305+ bestUpdatedAt = typeof best === "undefined" ? "" : best.lastUpdate || "";
306+ if (typeof best === "undefined" || updatedAt > bestUpdatedAt
307+ || (updatedAt === bestUpdatedAt && disruption.id < best.id)) {
302308 best = disruption;
303- bestPriority = priority;
304309 }
305310 }
306311 if (typeof best === "undefined") result.state = "NORMAL";
307- else if (bestPriority === 1) {
308- result.state = "DELAYED";
309- result.title = best.title;
310- result.text = best.text;
311- } else if (bestPriority === 3) {
312- result.state = "STOPPED";
313- result.title = best.title;
314- result.text = best.text;
312+ else {
313+ details = best;
314+ if ((typeof best.title === "undefined" || typeof best.text === "undefined") && frenchEnvelope) {
315+ fallbackNow = currentParisTimestamp(fallbackAtMilliseconds);
316+ details = frenchEnvelope.disruptions[best.id];
317+ // Translation never changes the selected incident or its severity.
318+ if (!active(best, fallbackNow) || typeof details === "undefined"
319+ || !active(details, fallbackNow)) return result;
320+ }
321+ if (typeof details.title !== "undefined" && typeof details.text !== "undefined") {
322+ result.state = best.severity === "BLOQUANTE" ? "STOPPED" : "DELAYED";
323+ result.title = details.title;
324+ result.text = details.text;
325+ }
315326 }
316327 return result;
317328 }
@@ -216,14 +216,16 @@ function normalizePrimTrafficResponse(value, apiKey) {
216 if (begin > end) fail();216 if (begin > end) fail();
217 periods.push(Object.freeze({ begin: begin, end: end }));217 periods.push(Object.freeze({ begin: begin, end: end }));
218 }218 }
219- if (typeof candidate.lastUpdate !== "undefined") localTimestamp(candidate.lastUpdate);
220 if (typeof candidate.title !== "undefined" && typeof candidate.title !== "string") fail();219 if (typeof candidate.title !== "undefined" && typeof candidate.title !== "string") fail();
221 if (typeof candidate.message !== "undefined" && typeof candidate.message !== "string") fail();220 if (typeof candidate.message !== "undefined" && typeof candidate.message !== "string") fail();
221+ if (typeof candidate.cause !== "undefined" && typeof candidate.cause !== "string") fail();
222 title = typeof candidate.title === "undefined" ? undefined222 title = typeof candidate.title === "undefined" ? undefined
223 : plainBoundedText(candidate.title, contracts.LIMITS.trafficTitleUtf8Bytes, variants);223 : plainBoundedText(candidate.title, contracts.LIMITS.trafficTitleUtf8Bytes, variants);
224 text = typeof candidate.message === "undefined" ? undefined224 text = typeof candidate.message === "undefined" ? undefined
225 : plainBoundedText(candidate.message, contracts.LIMITS.trafficTextUtf8Bytes, variants);225 : plainBoundedText(candidate.message, contracts.LIMITS.trafficTextUtf8Bytes, variants);
226 disruption = { id: candidate.id, severity: parseSeverity(candidate.severity), periods: Object.freeze(periods) };226 disruption = { id: candidate.id, severity: parseSeverity(candidate.severity), periods: Object.freeze(periods) };
227+ if (typeof candidate.cause !== "undefined") disruption.cause = candidate.cause;
228+ if (typeof candidate.lastUpdate !== "undefined") disruption.lastUpdate = localTimestamp(candidate.lastUpdate);
227 if (typeof title !== "undefined") disruption.title = title;229 if (typeof title !== "undefined") disruption.title = title;
228 if (typeof text !== "undefined") disruption.text = text;230 if (typeof text !== "undefined") disruption.text = text;
229 disruptions[candidate.id] = Object.freeze(disruption);231 disruptions[candidate.id] = Object.freeze(disruption);
@@ -272,13 +274,15 @@ function active(disruption, localNow) {
272 return false;274 return false;
273 }275 }
274 276
275-function trafficForLine(envelope, lineId, evaluatedAtMilliseconds, checkedAtMilliseconds) {277+function trafficForLine(envelope, lineId, evaluatedAtMilliseconds, checkedAtMilliseconds, frenchEnvelope, fallbackAtMilliseconds) {
276 var localNow = currentParisTimestamp(evaluatedAtMilliseconds);278 var localNow = currentParisTimestamp(evaluatedAtMilliseconds);
277 var line = envelope.lines[lineId];279 var line = envelope.lines[lineId];
278 var best;280 var best;
279- var bestPriority = 0;281+ var details;
282+ var fallbackNow;
280 var disruption;283 var disruption;
281- var priority;284+ var updatedAt;
285+ var bestUpdatedAt;
282 var index;286 var index;
283 var result = {287 var result = {
284 schemaVersion: contracts.SCHEMA_VERSION,288 schemaVersion: contracts.SCHEMA_VERSION,
@@ -294,24 +298,31 @@ function trafficForLine(envelope, lineId, evaluatedAtMilliseconds, checkedAtMill
294 }298 }
295 for (index = 0; index < line.length; index += 1) {299 for (index = 0; index < line.length; index += 1) {
296 disruption = envelope.disruptions[line[index]];300 disruption = envelope.disruptions[line[index]];
297- if (!active(disruption, localNow) || disruption.severity === "INFORMATION") continue;301+ // Works never surface as traffic, including works already under way.
298- if (typeof disruption.title === "undefined" || typeof disruption.text === "undefined") priority = 2;302+ if (disruption.cause === "TRAVAUX" || !active(disruption, localNow)
299- else priority = disruption.severity === "BLOQUANTE" ? 3 : 1;303+ || disruption.severity === "INFORMATION") continue;
300- if (typeof best === "undefined" || priority > bestPriority304+ updatedAt = disruption.lastUpdate || "";
301- || (priority === bestPriority && disruption.id < best.id)) {305+ bestUpdatedAt = typeof best === "undefined" ? "" : best.lastUpdate || "";
306+ if (typeof best === "undefined" || updatedAt > bestUpdatedAt
307+ || (updatedAt === bestUpdatedAt && disruption.id < best.id)) {
302 best = disruption;308 best = disruption;
303- bestPriority = priority;
304 }309 }
305 }310 }
306 if (typeof best === "undefined") result.state = "NORMAL";311 if (typeof best === "undefined") result.state = "NORMAL";
307- else if (bestPriority === 1) {312+ else {
308- result.state = "DELAYED";313+ details = best;
309- result.title = best.title;314+ if ((typeof best.title === "undefined" || typeof best.text === "undefined") && frenchEnvelope) {
310- result.text = best.text;315+ fallbackNow = currentParisTimestamp(fallbackAtMilliseconds);
311- } else if (bestPriority === 3) {316+ details = frenchEnvelope.disruptions[best.id];
312- result.state = "STOPPED";317+ // Translation never changes the selected incident or its severity.
313- result.title = best.title;318+ if (!active(best, fallbackNow) || typeof details === "undefined"
314- result.text = best.text;319+ || !active(details, fallbackNow)) return result;
320+ }
321+ if (typeof details.title !== "undefined" && typeof details.text !== "undefined") {
322+ result.state = best.severity === "BLOQUANTE" ? "STOPPED" : "DELAYED";
323+ result.title = details.title;
324+ result.text = details.text;
325+ }
315 }326 }
316 return result;327 return result;
317 }328 }
modified packages/companion/test/companion.test.js +1 -1
@@ -586,7 +586,7 @@ test("traffic completion remains token-bound and its line cache is language-spec
586586 language = "fr"; closeWith(target, "KEEP", favorites); acknowledgeConfiguration(target);
587587 sendTraffic(target, "translated", FIRST);
588588 assert.equal(requests(target, "traffic").length, 2);
589- assert.equal(requests(target, "traffic")[1].headers["Accept-Language"], "fr");
589+ assert.equal(requests(target, "traffic")[1].headers["Accept-Language"], undefined);
590590 resolveProduction(target, favorites);
591591 assert.notDeepEqual(finalTransfer(target, "translated").records, finalTransfer(target, "old-traffic").records);
592592 target.companion.stop();
@@ -586,7 +586,7 @@ test("traffic completion remains token-bound and its line cache is language-spec
586 language = "fr"; closeWith(target, "KEEP", favorites); acknowledgeConfiguration(target);586 language = "fr"; closeWith(target, "KEEP", favorites); acknowledgeConfiguration(target);
587 sendTraffic(target, "translated", FIRST);587 sendTraffic(target, "translated", FIRST);
588 assert.equal(requests(target, "traffic").length, 2);588 assert.equal(requests(target, "traffic").length, 2);
589- assert.equal(requests(target, "traffic")[1].headers["Accept-Language"], "fr");589+ assert.equal(requests(target, "traffic")[1].headers["Accept-Language"], undefined);
590 resolveProduction(target, favorites);590 resolveProduction(target, favorites);
591 assert.notDeepEqual(finalTransfer(target, "translated").records, finalTransfer(target, "old-traffic").records);591 assert.notDeepEqual(finalTransfer(target, "translated").records, finalTransfer(target, "old-traffic").records);
592 target.companion.stop();592 target.companion.stop();
modified packages/companion/test/prim-client.test.js +188 -1
@@ -376,7 +376,7 @@ test("traffic calls coalesce across lines but not languages or credentials", fun
376376 requestTraffic(h, "IDFM:C100", anotherKey, KEY_B);
377377 assert.equal(h.transport.instances.length, 3);
378378 assert.equal(h.transport.instances[0].url, "https://prim.iledefrance-mobilites.fr/marketplace/disruptions_bulk/disruptions/v2");
379- assert.equal(h.transport.instances[0].headers["Accept-Language"], "fr");
379+ assert.equal(h.transport.instances[0].headers["Accept-Language"], undefined);
380380 assert.equal(h.transport.instances[1].headers["Accept-Language"], "en");
381381 assert.equal(h.transport.instances[2].headers.apikey, KEY_B);
382382 h.transport.instances[0].respond(200, trafficFixture());
@@ -452,6 +452,193 @@ test("credential changes never reuse another key's traffic cache and callbacks c
452452 assertError(result.values[2], "RATE_LIMITED", TRAFFIC_MS, 5);
453453 });
454454
455+test("English complete text needs one request and missing either field uses the same French incident pair", function () {
456+ ["complete", "title", "message"].forEach(function (missing) {
457+ var h = harness(TRAFFIC_MS);
458+ var result = collect();
459+ var english = trafficFixture();
460+ var french = trafficFixture();
461+ english.disruptions[0].title = "English title";
462+ english.disruptions[0].message = "English body";
463+ if (missing !== "complete") english.disruptions[0][missing] = "<p> </p>";
464+ requestTraffic(h, "IDFM:C200", result, KEY_A, "en");
465+ h.transport.instances[0].respond(200, english);
466+ h.clock.advance(0);
467+ if (missing === "complete") {
468+ assert.equal(h.transport.instances.length, 1);
469+ assert.equal(result.values[0].data.title, "English title");
470+ assert.equal(result.values[0].data.text, "English body");
471+ } else {
472+ assert.deepEqual(result.values, []);
473+ assert.equal(h.transport.instances.length, 2);
474+ assert.equal(h.transport.instances[1].headers["Accept-Language"], undefined);
475+ french.lines[1].impactedObjects[0].disruptionIds.push("blocking-unclassified");
476+ french.disruptions[0].severity = missing === "title" ? "INFORMATION" : "BLOQUANTE";
477+ french.disruptions[3].lastUpdate = "20260115T100000";
478+ // The French pair only supplies the same-ID body: its own works cause never re-selects or strips it.
479+ french.disruptions[0].cause = "TRAVAUX";
480+ h.transport.instances[1].respond(200, french);
481+ h.clock.advance(0);
482+ assert.deepEqual(result.values[0], { status: "AVAILABLE", data: {
483+ schemaVersion: 1, state: "DELAYED", checkedAt: TRAFFIC_MS / 1000,
484+ title: "Métro 2 : ralentissements", text: "Le trafic est ralenti & les temps d’attente sont allongés."
485+ } });
486+ }
487+ h.clock.advance(600000);
488+ assert.equal(h.transport.instances.length, missing === "complete" ? 1 : 2);
489+ });
490+});
491+
492+test("fallback shares a French flight across lines and direct French readers then reuses both fresh caches", function () {
493+ var h = harness(TRAFFIC_MS);
494+ var first = collect(), second = collect(), direct = collect();
495+ var english = trafficFixture();
496+ delete english.disruptions[0].message;
497+ delete english.disruptions[3].title;
498+ requestTraffic(h, "IDFM:C100", direct);
499+ requestTraffic(h, "IDFM:C200", first, KEY_A, "en");
500+ requestTraffic(h, "IDFM:C400", second, KEY_A, "en");
501+ h.transport.instances[1].respond(200, english);
502+ assert.equal(h.transport.instances.length, 2);
503+ h.transport.instances[0].respond(200, trafficFixture());
504+ h.clock.advance(0);
505+ assert.equal(direct.values[0].data.state, "NORMAL");
506+ assert.equal(first.values[0].data.title, "Métro 2 : ralentissements");
507+ assert.equal(second.values[0].data.title, "Tram T3 : incident");
508+ h.clock.advance(59000);
509+ requestTraffic(h, "IDFM:C200", first, KEY_A, "en");
510+ h.clock.advance(0);
511+ assert.equal(h.transport.instances.length, 2);
512+ assert.equal(first.values[1].data.checkedAt, TRAFFIC_MS / 1000);
513+ assert.equal(first.values[1].data.title, "Métro 2 : ralentissements");
514+ h.clock.advance(1000);
515+ var expired = requestTraffic(h, "IDFM:C200", first, KEY_A, "en");
516+ assert.equal(h.transport.instances.length, 3);
517+ expired.abort();
518+});
519+
520+test("cancellation moves with fallback, preserves other readers and suppresses late completions", function () {
521+ var h = harness(TRAFFIC_MS);
522+ var first = collect(), second = collect();
523+ var english = trafficFixture();
524+ delete english.disruptions[0].message;
525+ var one = requestTraffic(h, "IDFM:C200", first, KEY_A, "en");
526+ var two = requestTraffic(h, "IDFM:C200", second, KEY_A, "en");
527+ h.transport.instances[0].respond(200, english);
528+ var lateLoad = h.transport.instances[1].onload;
529+ one.abort();
530+ assert.equal(h.transport.instances[1].aborts, undefined);
531+ two.abort();
532+ assert.equal(h.transport.instances[1].aborts, 1);
533+ lateLoad();
534+ h.clock.advance(10000);
535+ assert.deepEqual(first.values, []);
536+ assert.deepEqual(second.values, []);
537+ var resumed = requestTraffic(h, "IDFM:C200", second, KEY_A, "en");
538+ assert.equal(h.transport.instances.length, 3);
539+ h.transport.instances[2].respond(200, trafficFixture());
540+ resumed.abort();
541+ h.clock.advance(0);
542+ assert.deepEqual(second.values, []);
543+});
544+
545+test("French fallback cannot replace a missing, inactive or incomplete matching incident with another one", function () {
546+ ["missing", "future", "expired", "incomplete", "selected-expired"].forEach(function (scenario) {
547+ var h = harness(TRAFFIC_MS);
548+ var result = collect();
549+ var english = trafficFixture(), french = trafficFixture();
550+ delete english.disruptions[0].message;
551+ if (scenario === "selected-expired") {
552+ english.disruptions[0].applicationPeriods[0].end = "20260115T100001";
553+ }
554+ if (scenario === "missing") {
555+ french.disruptions[0].id = "different-incident";
556+ french.lines.forEach(function (line) {
557+ line.impactedObjects.forEach(function (object) {
558+ object.disruptionIds = object.disruptionIds.map(function (id) {
559+ return id === "delay-active" ? "different-incident" : id;
560+ });
561+ });
562+ });
563+ } else if (scenario === "future") {
564+ french.disruptions[0].applicationPeriods[0].begin = "20260115T100002";
565+ } else if (scenario === "expired") {
566+ french.disruptions[0].applicationPeriods[0].end = "20260115T100001";
567+ } else if (scenario === "incomplete") delete french.disruptions[0].title;
568+ requestTraffic(h, "IDFM:C200", result, KEY_A, "en");
569+ h.transport.instances[0].respond(200, english);
570+ h.clock.advance(1000);
571+ h.transport.instances[1].respond(200, french);
572+ h.clock.advance(0);
573+ assert.deepEqual(result.values[0], { status: "AVAILABLE", data: {
574+ schemaVersion: 1, state: "UNKNOWN", checkedAt: TRAFFIC_MS / 1000
575+ } });
576+ assert.equal(h.transport.instances.length, 2);
577+ });
578+});
579+
580+test("English failures never fetch French and fallback failures propagate without retries", function () {
581+ ["english", "french"].forEach(function (failedLanguage) {
582+ var h = harness(TRAFFIC_MS), result = collect(), english = trafficFixture();
583+ delete english.disruptions[0].title;
584+ requestTraffic(h, "IDFM:C200", result, KEY_A, "en");
585+ if (failedLanguage === "french") h.transport.instances[0].respond(200, english);
586+ h.transport.instances[failedLanguage === "english" ? 0 : 1].respond(429, {}, { "Retry-After": "5" });
587+ h.clock.advance(600000);
588+ assertError(result.values[0], "RATE_LIMITED", TRAFFIC_MS, 5);
589+ assert.equal(h.transport.instances.length, failedLanguage === "english" ? 1 : 2);
590+ });
591+});
592+
593+test("fallback never serves an English envelope that expires while French is pending", function () {
594+ var h = harness(TRAFFIC_MS), result = collect(), english = trafficFixture();
595+ delete english.disruptions[0].title;
596+ requestTraffic(h, "IDFM:C100", result, KEY_A, "en");
597+ h.transport.instances[0].respond(200, english);
598+ h.clock.advance(59000);
599+ requestTraffic(h, "IDFM:C200", result, KEY_A, "en");
600+ h.clock.advance(1000);
601+ h.transport.instances[1].respond(200, trafficFixture());
602+ h.clock.advance(0);
603+ assertError(result.values[1], "INVALID_RESPONSE", TRAFFIC_MS + 60000);
604+ assert.equal(h.transport.instances.length, 2);
605+});
606+
607+test("fallback stays inside the shared eight-slot limiter and cancelled queued fallback never starts", function () {
608+ var h = harness(TRAFFIC_MS), result = collect(), english = trafficFixture(), handles = [];
609+ delete english.disruptions[0].title;
610+ requestTraffic(h, "IDFM:C100", result, KEY_A, "en");
611+ h.transport.instances[0].respond(200, english);
612+ h.clock.advance(0);
613+ for (var index = 0; index < 8; index += 1) {
614+ handles.push(h.client.departures({ routing: ROUTING, apiKey: KEY_A, context: CONTEXT }, result.complete));
615+ }
616+ var fallback = requestTraffic(h, "IDFM:C200", result, KEY_A, "en");
617+ assert.equal(h.transport.instances.length, 9);
618+ fallback.abort();
619+ handles.forEach(function (handle) { handle.abort(); });
620+ h.clock.advance(10000);
621+ assert.equal(h.transport.instances.length, 9);
622+ assert.equal(result.values.length, 1);
623+});
624+
625+test("fallback refreshes expired French data and never borrows another credential's French cache", function () {
626+ ["expired", "other-key"].forEach(function (scenario) {
627+ var h = harness(TRAFFIC_MS), result = collect(), english = trafficFixture();
628+ delete english.disruptions[0].title;
629+ requestTraffic(h, "IDFM:C200", result, scenario === "other-key" ? KEY_B : KEY_A);
630+ h.transport.instances[0].respond(200, trafficFixture());
631+ h.clock.advance(scenario === "expired" ? 60000 : 0);
632+ requestTraffic(h, "IDFM:C200", result, KEY_A, "en");
633+ h.transport.instances[1].respond(200, english);
634+ assert.equal(h.transport.instances.length, 3);
635+ assert.equal(h.transport.instances[2].headers.apikey, KEY_A);
636+ h.transport.instances[2].respond(503, {});
637+ h.clock.advance(0);
638+ assertError(result.values[1], "SOURCE_UNAVAILABLE", h.clock.now());
639+ });
640+});
641+
455642 test("transport errors and malformed traffic are unavailable, not successful UNKNOWN, and are not cached", function () {
456643 var h = harness(TRAFFIC_MS);
457644 var result = collect();
@@ -376,7 +376,7 @@ test("traffic calls coalesce across lines but not languages or credentials", fun
376 requestTraffic(h, "IDFM:C100", anotherKey, KEY_B);376 requestTraffic(h, "IDFM:C100", anotherKey, KEY_B);
377 assert.equal(h.transport.instances.length, 3);377 assert.equal(h.transport.instances.length, 3);
378 assert.equal(h.transport.instances[0].url, "https://prim.iledefrance-mobilites.fr/marketplace/disruptions_bulk/disruptions/v2");378 assert.equal(h.transport.instances[0].url, "https://prim.iledefrance-mobilites.fr/marketplace/disruptions_bulk/disruptions/v2");
379- assert.equal(h.transport.instances[0].headers["Accept-Language"], "fr");379+ assert.equal(h.transport.instances[0].headers["Accept-Language"], undefined);
380 assert.equal(h.transport.instances[1].headers["Accept-Language"], "en");380 assert.equal(h.transport.instances[1].headers["Accept-Language"], "en");
381 assert.equal(h.transport.instances[2].headers.apikey, KEY_B);381 assert.equal(h.transport.instances[2].headers.apikey, KEY_B);
382 h.transport.instances[0].respond(200, trafficFixture());382 h.transport.instances[0].respond(200, trafficFixture());
@@ -452,6 +452,193 @@ test("credential changes never reuse another key's traffic cache and callbacks c
452 assertError(result.values[2], "RATE_LIMITED", TRAFFIC_MS, 5);452 assertError(result.values[2], "RATE_LIMITED", TRAFFIC_MS, 5);
453 });453 });
454 454
455+test("English complete text needs one request and missing either field uses the same French incident pair", function () {
456+ ["complete", "title", "message"].forEach(function (missing) {
457+ var h = harness(TRAFFIC_MS);
458+ var result = collect();
459+ var english = trafficFixture();
460+ var french = trafficFixture();
461+ english.disruptions[0].title = "English title";
462+ english.disruptions[0].message = "English body";
463+ if (missing !== "complete") english.disruptions[0][missing] = "<p> </p>";
464+ requestTraffic(h, "IDFM:C200", result, KEY_A, "en");
465+ h.transport.instances[0].respond(200, english);
466+ h.clock.advance(0);
467+ if (missing === "complete") {
468+ assert.equal(h.transport.instances.length, 1);
469+ assert.equal(result.values[0].data.title, "English title");
470+ assert.equal(result.values[0].data.text, "English body");
471+ } else {
472+ assert.deepEqual(result.values, []);
473+ assert.equal(h.transport.instances.length, 2);
474+ assert.equal(h.transport.instances[1].headers["Accept-Language"], undefined);
475+ french.lines[1].impactedObjects[0].disruptionIds.push("blocking-unclassified");
476+ french.disruptions[0].severity = missing === "title" ? "INFORMATION" : "BLOQUANTE";
477+ french.disruptions[3].lastUpdate = "20260115T100000";
478+ // The French pair only supplies the same-ID body: its own works cause never re-selects or strips it.
479+ french.disruptions[0].cause = "TRAVAUX";
480+ h.transport.instances[1].respond(200, french);
481+ h.clock.advance(0);
482+ assert.deepEqual(result.values[0], { status: "AVAILABLE", data: {
483+ schemaVersion: 1, state: "DELAYED", checkedAt: TRAFFIC_MS / 1000,
484+ title: "Métro 2 : ralentissements", text: "Le trafic est ralenti & les temps d’attente sont allongés."
485+ } });
486+ }
487+ h.clock.advance(600000);
488+ assert.equal(h.transport.instances.length, missing === "complete" ? 1 : 2);
489+ });
490+});
491+
492+test("fallback shares a French flight across lines and direct French readers then reuses both fresh caches", function () {
493+ var h = harness(TRAFFIC_MS);
494+ var first = collect(), second = collect(), direct = collect();
495+ var english = trafficFixture();
496+ delete english.disruptions[0].message;
497+ delete english.disruptions[3].title;
498+ requestTraffic(h, "IDFM:C100", direct);
499+ requestTraffic(h, "IDFM:C200", first, KEY_A, "en");
500+ requestTraffic(h, "IDFM:C400", second, KEY_A, "en");
501+ h.transport.instances[1].respond(200, english);
502+ assert.equal(h.transport.instances.length, 2);
503+ h.transport.instances[0].respond(200, trafficFixture());
504+ h.clock.advance(0);
505+ assert.equal(direct.values[0].data.state, "NORMAL");
506+ assert.equal(first.values[0].data.title, "Métro 2 : ralentissements");
507+ assert.equal(second.values[0].data.title, "Tram T3 : incident");
508+ h.clock.advance(59000);
509+ requestTraffic(h, "IDFM:C200", first, KEY_A, "en");
510+ h.clock.advance(0);
511+ assert.equal(h.transport.instances.length, 2);
512+ assert.equal(first.values[1].data.checkedAt, TRAFFIC_MS / 1000);
513+ assert.equal(first.values[1].data.title, "Métro 2 : ralentissements");
514+ h.clock.advance(1000);
515+ var expired = requestTraffic(h, "IDFM:C200", first, KEY_A, "en");
516+ assert.equal(h.transport.instances.length, 3);
517+ expired.abort();
518+});
519+
520+test("cancellation moves with fallback, preserves other readers and suppresses late completions", function () {
521+ var h = harness(TRAFFIC_MS);
522+ var first = collect(), second = collect();
523+ var english = trafficFixture();
524+ delete english.disruptions[0].message;
525+ var one = requestTraffic(h, "IDFM:C200", first, KEY_A, "en");
526+ var two = requestTraffic(h, "IDFM:C200", second, KEY_A, "en");
527+ h.transport.instances[0].respond(200, english);
528+ var lateLoad = h.transport.instances[1].onload;
529+ one.abort();
530+ assert.equal(h.transport.instances[1].aborts, undefined);
531+ two.abort();
532+ assert.equal(h.transport.instances[1].aborts, 1);
533+ lateLoad();
534+ h.clock.advance(10000);
535+ assert.deepEqual(first.values, []);
536+ assert.deepEqual(second.values, []);
537+ var resumed = requestTraffic(h, "IDFM:C200", second, KEY_A, "en");
538+ assert.equal(h.transport.instances.length, 3);
539+ h.transport.instances[2].respond(200, trafficFixture());
540+ resumed.abort();
541+ h.clock.advance(0);
542+ assert.deepEqual(second.values, []);
543+});
544+
545+test("French fallback cannot replace a missing, inactive or incomplete matching incident with another one", function () {
546+ ["missing", "future", "expired", "incomplete", "selected-expired"].forEach(function (scenario) {
547+ var h = harness(TRAFFIC_MS);
548+ var result = collect();
549+ var english = trafficFixture(), french = trafficFixture();
550+ delete english.disruptions[0].message;
551+ if (scenario === "selected-expired") {
552+ english.disruptions[0].applicationPeriods[0].end = "20260115T100001";
553+ }
554+ if (scenario === "missing") {
555+ french.disruptions[0].id = "different-incident";
556+ french.lines.forEach(function (line) {
557+ line.impactedObjects.forEach(function (object) {
558+ object.disruptionIds = object.disruptionIds.map(function (id) {
559+ return id === "delay-active" ? "different-incident" : id;
560+ });
561+ });
562+ });
563+ } else if (scenario === "future") {
564+ french.disruptions[0].applicationPeriods[0].begin = "20260115T100002";
565+ } else if (scenario === "expired") {
566+ french.disruptions[0].applicationPeriods[0].end = "20260115T100001";
567+ } else if (scenario === "incomplete") delete french.disruptions[0].title;
568+ requestTraffic(h, "IDFM:C200", result, KEY_A, "en");
569+ h.transport.instances[0].respond(200, english);
570+ h.clock.advance(1000);
571+ h.transport.instances[1].respond(200, french);
572+ h.clock.advance(0);
573+ assert.deepEqual(result.values[0], { status: "AVAILABLE", data: {
574+ schemaVersion: 1, state: "UNKNOWN", checkedAt: TRAFFIC_MS / 1000
575+ } });
576+ assert.equal(h.transport.instances.length, 2);
577+ });
578+});
579+
580+test("English failures never fetch French and fallback failures propagate without retries", function () {
581+ ["english", "french"].forEach(function (failedLanguage) {
582+ var h = harness(TRAFFIC_MS), result = collect(), english = trafficFixture();
583+ delete english.disruptions[0].title;
584+ requestTraffic(h, "IDFM:C200", result, KEY_A, "en");
585+ if (failedLanguage === "french") h.transport.instances[0].respond(200, english);
586+ h.transport.instances[failedLanguage === "english" ? 0 : 1].respond(429, {}, { "Retry-After": "5" });
587+ h.clock.advance(600000);
588+ assertError(result.values[0], "RATE_LIMITED", TRAFFIC_MS, 5);
589+ assert.equal(h.transport.instances.length, failedLanguage === "english" ? 1 : 2);
590+ });
591+});
592+
593+test("fallback never serves an English envelope that expires while French is pending", function () {
594+ var h = harness(TRAFFIC_MS), result = collect(), english = trafficFixture();
595+ delete english.disruptions[0].title;
596+ requestTraffic(h, "IDFM:C100", result, KEY_A, "en");
597+ h.transport.instances[0].respond(200, english);
598+ h.clock.advance(59000);
599+ requestTraffic(h, "IDFM:C200", result, KEY_A, "en");
600+ h.clock.advance(1000);
601+ h.transport.instances[1].respond(200, trafficFixture());
602+ h.clock.advance(0);
603+ assertError(result.values[1], "INVALID_RESPONSE", TRAFFIC_MS + 60000);
604+ assert.equal(h.transport.instances.length, 2);
605+});
606+
607+test("fallback stays inside the shared eight-slot limiter and cancelled queued fallback never starts", function () {
608+ var h = harness(TRAFFIC_MS), result = collect(), english = trafficFixture(), handles = [];
609+ delete english.disruptions[0].title;
610+ requestTraffic(h, "IDFM:C100", result, KEY_A, "en");
611+ h.transport.instances[0].respond(200, english);
612+ h.clock.advance(0);
613+ for (var index = 0; index < 8; index += 1) {
614+ handles.push(h.client.departures({ routing: ROUTING, apiKey: KEY_A, context: CONTEXT }, result.complete));
615+ }
616+ var fallback = requestTraffic(h, "IDFM:C200", result, KEY_A, "en");
617+ assert.equal(h.transport.instances.length, 9);
618+ fallback.abort();
619+ handles.forEach(function (handle) { handle.abort(); });
620+ h.clock.advance(10000);
621+ assert.equal(h.transport.instances.length, 9);
622+ assert.equal(result.values.length, 1);
623+});
624+
625+test("fallback refreshes expired French data and never borrows another credential's French cache", function () {
626+ ["expired", "other-key"].forEach(function (scenario) {
627+ var h = harness(TRAFFIC_MS), result = collect(), english = trafficFixture();
628+ delete english.disruptions[0].title;
629+ requestTraffic(h, "IDFM:C200", result, scenario === "other-key" ? KEY_B : KEY_A);
630+ h.transport.instances[0].respond(200, trafficFixture());
631+ h.clock.advance(scenario === "expired" ? 60000 : 0);
632+ requestTraffic(h, "IDFM:C200", result, KEY_A, "en");
633+ h.transport.instances[1].respond(200, english);
634+ assert.equal(h.transport.instances.length, 3);
635+ assert.equal(h.transport.instances[2].headers.apikey, KEY_A);
636+ h.transport.instances[2].respond(503, {});
637+ h.clock.advance(0);
638+ assertError(result.values[1], "SOURCE_UNAVAILABLE", h.clock.now());
639+ });
640+});
641+
455 test("transport errors and malformed traffic are unavailable, not successful UNKNOWN, and are not cached", function () {642 test("transport errors and malformed traffic are unavailable, not successful UNKNOWN, and are not cached", function () {
456 var h = harness(TRAFFIC_MS);643 var h = harness(TRAFFIC_MS);
457 var result = collect();644 var result = collect();
modified packages/companion/test/prim-normalization.test.js +49 -4
@@ -401,15 +401,59 @@ test("lines omitted from the disruption-only bulk feed have normal traffic", fun
401401 assert.equal(trafficDetail(payload, "invalid-line").state, "UNKNOWN");
402402 });
403403
404-test("BLOQUANTE produces STOPPED and outranks usable PERTURBEE details", function () {
404+test("the latest active update wins even over a more severe incident", function () {
405405 var payload = trafficFixture();
406406 payload.lines[1].impactedObjects[0].disruptionIds = ["delay-active", "blocking-unclassified"];
407+ payload.disruptions[0].lastUpdate = "20260115T095900";
407408 assert.deepEqual(trafficDetail(payload, "IDFM:C200"), {
408- schemaVersion: 1, state: "STOPPED", checkedAt: TRAFFIC_MS / 1000,
409- title: "Tram T3 : incident", text: "Une perturbation est en cours."
409+ schemaVersion: 1, state: "DELAYED", checkedAt: TRAFFIC_MS / 1000,
410+ title: "Métro 2 : ralentissements", text: "Le trafic est ralenti & les temps d’attente sont allongés."
410411 });
411412 payload.lines[1].impactedObjects[0].disruptionIds.reverse();
412- assert.equal(trafficDetail(payload, "IDFM:C200").state, "STOPPED");
413+ assert.equal(trafficDetail(payload, "IDFM:C200").state, "DELAYED");
414+});
415+
416+test("dated incidents outrank undated ones and equal dates use ID rather than input order", function () {
417+ var payload = trafficFixture();
418+ payload.lines[1].impactedObjects[0].disruptionIds = ["delay-active", "blocking-unclassified"];
419+ delete payload.disruptions[3].lastUpdate;
420+ assert.equal(trafficDetail(payload, "IDFM:C200").state, "DELAYED");
421+ payload.disruptions[3].lastUpdate = payload.disruptions[0].lastUpdate;
422+ assert.equal(trafficDetail(payload, "IDFM:C200").title, "Tram T3 : incident");
423+ payload.lines[1].impactedObjects[0].disruptionIds.reverse();
424+ assert.equal(trafficDetail(payload, "IDFM:C200").title, "Tram T3 : incident");
425+ delete payload.disruptions[0].lastUpdate;
426+ delete payload.disruptions[3].lastUpdate;
427+ assert.equal(trafficDetail(payload, "IDFM:C200").title, "Tram T3 : incident");
428+});
429+
430+test("newer future and active works are excluded and cannot mask older active incidents", function () {
431+ var payload = trafficFixture();
432+ payload.lines[1].impactedObjects[0].disruptionIds = ["delay-active", "future-delay", "blocking-unclassified"];
433+ payload.disruptions[3].lastUpdate = "20260115T095900";
434+ payload.disruptions[3].applicationPeriods = [{ begin: "20260115T090000", end: "20260115T100000" }];
435+ payload.disruptions[4].lastUpdate = "20260115T100000";
436+ assert.equal(trafficDetail(payload, "IDFM:C200").title, "Métro 2 : ralentissements");
437+ payload.disruptions[4].applicationPeriods[0].begin = "20260115T100000";
438+ assert.equal(trafficDetail(payload, "IDFM:C200").title, "Métro 2 : ralentissements");
439+ payload.lines[1].impactedObjects[0].disruptionIds = ["future-delay"];
440+ assert.equal(trafficDetail(payload, "IDFM:C200").state, "NORMAL");
441+});
442+
443+test("works exclusion follows the explicit TRAVAUX cause, never the title, a missing or unknown cause", function () {
444+ var payload = trafficFixture();
445+ payload.lines[1].impactedObjects[0].disruptionIds = ["delay-active", "future-delay"];
446+ payload.disruptions[4].applicationPeriods[0].begin = "20260115T100000";
447+ payload.disruptions[4].lastUpdate = "20260115T100000";
448+ payload.disruptions[0].lastUpdate = "20260115T090000";
449+ payload.disruptions[0].title = "Travaux en cours sur la ligne";
450+ assert.equal(trafficDetail(payload, "IDFM:C200").title, "Travaux en cours sur la ligne");
451+ payload.disruptions[0].cause = "TRAVAUX";
452+ assert.equal(trafficDetail(payload, "IDFM:C200").state, "NORMAL");
453+ payload.disruptions[0].cause = "MANIFESTATION";
454+ assert.equal(trafficDetail(payload, "IDFM:C200").title, "Travaux en cours sur la ligne");
455+ delete payload.disruptions[0].cause;
456+ assert.equal(trafficDetail(payload, "IDFM:C200").title, "Travaux en cours sur la ligne");
413457 });
414458
415459 test("an active disruption without complete details remains UNKNOWN", function () {
@@ -504,6 +548,7 @@ test("malformed traffic joins, periods, duplicates and severities reject rather
504548 function (payload) { delete payload.disruptions; },
505549 function (payload) { payload.lines[1].impactedObjects[0].disruptionIds = ["missing"]; },
506550 function (payload) { payload.disruptions[0].severity = "MAJEURE"; },
551+ function (payload) { payload.disruptions[0].lastUpdate = "20260230T100000"; },
507552 function (payload) { payload.disruptions[0].applicationPeriods = [{ begin: "20260230T100000", end: "20260230T110000" }]; },
508553 function (payload) { payload.disruptions[0].applicationPeriods[0].begin += "\n"; },
509554 function (payload) { payload.lines.push(clone(payload.lines[0])); },
@@ -401,15 +401,59 @@ test("lines omitted from the disruption-only bulk feed have normal traffic", fun
401 assert.equal(trafficDetail(payload, "invalid-line").state, "UNKNOWN");401 assert.equal(trafficDetail(payload, "invalid-line").state, "UNKNOWN");
402 });402 });
403 403
404-test("BLOQUANTE produces STOPPED and outranks usable PERTURBEE details", function () {404+test("the latest active update wins even over a more severe incident", function () {
405 var payload = trafficFixture();405 var payload = trafficFixture();
406 payload.lines[1].impactedObjects[0].disruptionIds = ["delay-active", "blocking-unclassified"];406 payload.lines[1].impactedObjects[0].disruptionIds = ["delay-active", "blocking-unclassified"];
407+ payload.disruptions[0].lastUpdate = "20260115T095900";
407 assert.deepEqual(trafficDetail(payload, "IDFM:C200"), {408 assert.deepEqual(trafficDetail(payload, "IDFM:C200"), {
408- schemaVersion: 1, state: "STOPPED", checkedAt: TRAFFIC_MS / 1000,409+ schemaVersion: 1, state: "DELAYED", checkedAt: TRAFFIC_MS / 1000,
409- title: "Tram T3 : incident", text: "Une perturbation est en cours."410+ title: "Métro 2 : ralentissements", text: "Le trafic est ralenti & les temps d’attente sont allongés."
410 });411 });
411 payload.lines[1].impactedObjects[0].disruptionIds.reverse();412 payload.lines[1].impactedObjects[0].disruptionIds.reverse();
412- assert.equal(trafficDetail(payload, "IDFM:C200").state, "STOPPED");413+ assert.equal(trafficDetail(payload, "IDFM:C200").state, "DELAYED");
414+});
415+
416+test("dated incidents outrank undated ones and equal dates use ID rather than input order", function () {
417+ var payload = trafficFixture();
418+ payload.lines[1].impactedObjects[0].disruptionIds = ["delay-active", "blocking-unclassified"];
419+ delete payload.disruptions[3].lastUpdate;
420+ assert.equal(trafficDetail(payload, "IDFM:C200").state, "DELAYED");
421+ payload.disruptions[3].lastUpdate = payload.disruptions[0].lastUpdate;
422+ assert.equal(trafficDetail(payload, "IDFM:C200").title, "Tram T3 : incident");
423+ payload.lines[1].impactedObjects[0].disruptionIds.reverse();
424+ assert.equal(trafficDetail(payload, "IDFM:C200").title, "Tram T3 : incident");
425+ delete payload.disruptions[0].lastUpdate;
426+ delete payload.disruptions[3].lastUpdate;
427+ assert.equal(trafficDetail(payload, "IDFM:C200").title, "Tram T3 : incident");
428+});
429+
430+test("newer future and active works are excluded and cannot mask older active incidents", function () {
431+ var payload = trafficFixture();
432+ payload.lines[1].impactedObjects[0].disruptionIds = ["delay-active", "future-delay", "blocking-unclassified"];
433+ payload.disruptions[3].lastUpdate = "20260115T095900";
434+ payload.disruptions[3].applicationPeriods = [{ begin: "20260115T090000", end: "20260115T100000" }];
435+ payload.disruptions[4].lastUpdate = "20260115T100000";
436+ assert.equal(trafficDetail(payload, "IDFM:C200").title, "Métro 2 : ralentissements");
437+ payload.disruptions[4].applicationPeriods[0].begin = "20260115T100000";
438+ assert.equal(trafficDetail(payload, "IDFM:C200").title, "Métro 2 : ralentissements");
439+ payload.lines[1].impactedObjects[0].disruptionIds = ["future-delay"];
440+ assert.equal(trafficDetail(payload, "IDFM:C200").state, "NORMAL");
441+});
442+
443+test("works exclusion follows the explicit TRAVAUX cause, never the title, a missing or unknown cause", function () {
444+ var payload = trafficFixture();
445+ payload.lines[1].impactedObjects[0].disruptionIds = ["delay-active", "future-delay"];
446+ payload.disruptions[4].applicationPeriods[0].begin = "20260115T100000";
447+ payload.disruptions[4].lastUpdate = "20260115T100000";
448+ payload.disruptions[0].lastUpdate = "20260115T090000";
449+ payload.disruptions[0].title = "Travaux en cours sur la ligne";
450+ assert.equal(trafficDetail(payload, "IDFM:C200").title, "Travaux en cours sur la ligne");
451+ payload.disruptions[0].cause = "TRAVAUX";
452+ assert.equal(trafficDetail(payload, "IDFM:C200").state, "NORMAL");
453+ payload.disruptions[0].cause = "MANIFESTATION";
454+ assert.equal(trafficDetail(payload, "IDFM:C200").title, "Travaux en cours sur la ligne");
455+ delete payload.disruptions[0].cause;
456+ assert.equal(trafficDetail(payload, "IDFM:C200").title, "Travaux en cours sur la ligne");
413 });457 });
414 458
415 test("an active disruption without complete details remains UNKNOWN", function () {459 test("an active disruption without complete details remains UNKNOWN", function () {
@@ -504,6 +548,7 @@ test("malformed traffic joins, periods, duplicates and severities reject rather
504 function (payload) { delete payload.disruptions; },548 function (payload) { delete payload.disruptions; },
505 function (payload) { payload.lines[1].impactedObjects[0].disruptionIds = ["missing"]; },549 function (payload) { payload.lines[1].impactedObjects[0].disruptionIds = ["missing"]; },
506 function (payload) { payload.disruptions[0].severity = "MAJEURE"; },550 function (payload) { payload.disruptions[0].severity = "MAJEURE"; },
551+ function (payload) { payload.disruptions[0].lastUpdate = "20260230T100000"; },
507 function (payload) { payload.disruptions[0].applicationPeriods = [{ begin: "20260230T100000", end: "20260230T110000" }]; },552 function (payload) { payload.disruptions[0].applicationPeriods = [{ begin: "20260230T100000", end: "20260230T110000" }]; },
508 function (payload) { payload.disruptions[0].applicationPeriods[0].begin += "\n"; },553 function (payload) { payload.disruptions[0].applicationPeriods[0].begin += "\n"; },
509 function (payload) { payload.lines.push(clone(payload.lines[0])); },554 function (payload) { payload.lines.push(clone(payload.lines[0])); },