fix(traffic): show the latest active incident and exclude works
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 | ||
| 41 | 41 | |
| 42 | 42 | ## 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 | 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 | 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 | 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 | 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 | 49 | - **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 | ## Features | 42 | ## 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) { | ||
| 131 | 131 | event.timer = null; |
| 132 | 132 | event.complete = null; |
| 133 | 133 | event.outcome = null; |
| 134 | + event.resolve = null; | |
| 134 | 135 | onCancel = event.onCancel; |
| 135 | 136 | event.onCancel = null; |
| 136 | 137 | if (onCancel) onCancel(); |
| @@ -142,6 +143,7 @@ function createPrimClient(options) { | ||
| 142 | 143 | if (event.settled || event.scheduled) return; |
| 143 | 144 | event.scheduled = true; |
| 144 | 145 | event.onCancel = null; |
| 146 | + event.resolve = null; | |
| 145 | 147 | event.outcome = outcome; |
| 146 | 148 | event.timer = later(function () { |
| 147 | 149 | var complete; |
| @@ -265,7 +267,7 @@ function createPrimClient(options) { | ||
| 265 | 267 | xhr.responseType = "arraybuffer"; |
| 266 | 268 | xhr.timeout = contracts.LIMITS.httpTimeoutMs; |
| 267 | 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 | 271 | xhr.setRequestHeader("apikey", request.apiKey); |
| 270 | 272 | request.apiKey = null; |
| 271 | 273 | xhr.onload = function () { receive(request); }; |
| @@ -342,7 +344,6 @@ function createPrimClient(options) { | ||
| 342 | 344 | var receivedAt = now(); |
| 343 | 345 | var index; |
| 344 | 346 | var event; |
| 345 | - var result; | |
| 346 | 347 | removeFlight(flight); |
| 347 | 348 | flight.subscribers = []; |
| 348 | 349 | flight.handle = null; |
| @@ -355,61 +356,34 @@ function createPrimClient(options) { | ||
| 355 | 356 | flight.apiKey = null; |
| 356 | 357 | for (index = 0; index < subscribers.length; index += 1) { |
| 357 | 358 | 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); | |
| 365 | 361 | } |
| 366 | 362 | } |
| 367 | 363 | |
| 368 | - function traffic(request, complete) { | |
| 369 | - var event = subscriber(complete); | |
| 370 | - var lineId; | |
| 371 | - var cached; | |
| 364 | + function subscribeTraffic(event, language, apiKey, resolve) { | |
| 372 | 365 | var startedAt = now(); |
| 366 | + var cached = trafficCache[language]; | |
| 373 | 367 | var age; |
| 374 | 368 | var flight; |
| 375 | 369 | 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; | |
| 394 | 371 | if (cached !== null) { |
| 395 | 372 | 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; | |
| 401 | 376 | } |
| 402 | - trafficCache[request.language] = null; | |
| 377 | + trafficCache[language] = null; | |
| 403 | 378 | } |
| 404 | - event.lineId = lineId; | |
| 405 | 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 | 381 | flight = trafficFlights[index]; |
| 408 | 382 | break; |
| 409 | 383 | } |
| 410 | 384 | } |
| 411 | 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 | 387 | trafficFlights.push(flight); |
| 414 | 388 | } |
| 415 | 389 | flight.subscribers.push(event); |
| @@ -428,6 +402,65 @@ function createPrimClient(options) { | ||
| 428 | 402 | return { storedAt: receivedAt, envelope: trafficParser.normalizePrimTrafficResponse(payload, flight.apiKey) }; |
| 429 | 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 | 464 | return event.handle; |
| 432 | 465 | } |
| 433 | 466 | |
| @@ -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) { | ||
| 216 | 216 | if (begin > end) fail(); |
| 217 | 217 | periods.push(Object.freeze({ begin: begin, end: end })); |
| 218 | 218 | } |
| 219 | - if (typeof candidate.lastUpdate !== "undefined") localTimestamp(candidate.lastUpdate); | |
| 220 | 219 | if (typeof candidate.title !== "undefined" && typeof candidate.title !== "string") fail(); |
| 221 | 220 | if (typeof candidate.message !== "undefined" && typeof candidate.message !== "string") fail(); |
| 221 | + if (typeof candidate.cause !== "undefined" && typeof candidate.cause !== "string") fail(); | |
| 222 | 222 | title = typeof candidate.title === "undefined" ? undefined |
| 223 | 223 | : plainBoundedText(candidate.title, contracts.LIMITS.trafficTitleUtf8Bytes, variants); |
| 224 | 224 | text = typeof candidate.message === "undefined" ? undefined |
| 225 | 225 | : plainBoundedText(candidate.message, contracts.LIMITS.trafficTextUtf8Bytes, variants); |
| 226 | 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 | 229 | if (typeof title !== "undefined") disruption.title = title; |
| 228 | 230 | if (typeof text !== "undefined") disruption.text = text; |
| 229 | 231 | disruptions[candidate.id] = Object.freeze(disruption); |
| @@ -272,13 +274,15 @@ function active(disruption, localNow) { | ||
| 272 | 274 | return false; |
| 273 | 275 | } |
| 274 | 276 | |
| 275 | -function trafficForLine(envelope, lineId, evaluatedAtMilliseconds, checkedAtMilliseconds) { | |
| 277 | +function trafficForLine(envelope, lineId, evaluatedAtMilliseconds, checkedAtMilliseconds, frenchEnvelope, fallbackAtMilliseconds) { | |
| 276 | 278 | var localNow = currentParisTimestamp(evaluatedAtMilliseconds); |
| 277 | 279 | var line = envelope.lines[lineId]; |
| 278 | 280 | var best; |
| 279 | - var bestPriority = 0; | |
| 281 | + var details; | |
| 282 | + var fallbackNow; | |
| 280 | 283 | var disruption; |
| 281 | - var priority; | |
| 284 | + var updatedAt; | |
| 285 | + var bestUpdatedAt; | |
| 282 | 286 | var index; |
| 283 | 287 | var result = { |
| 284 | 288 | schemaVersion: contracts.SCHEMA_VERSION, |
| @@ -294,24 +298,31 @@ function trafficForLine(envelope, lineId, evaluatedAtMilliseconds, checkedAtMill | ||
| 294 | 298 | } |
| 295 | 299 | for (index = 0; index < line.length; index += 1) { |
| 296 | 300 | 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)) { | |
| 302 | 308 | best = disruption; |
| 303 | - bestPriority = priority; | |
| 304 | 309 | } |
| 305 | 310 | } |
| 306 | 311 | 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 | + } | |
| 315 | 326 | } |
| 316 | 327 | return result; |
| 317 | 328 | } |
| @@ -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" ? undefined | 222 | 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" ? undefined | 224 | 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 > bestPriority | 304 | + 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 | ||
| 586 | 586 | language = "fr"; closeWith(target, "KEEP", favorites); acknowledgeConfiguration(target); |
| 587 | 587 | sendTraffic(target, "translated", FIRST); |
| 588 | 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 | 590 | resolveProduction(target, favorites); |
| 591 | 591 | assert.notDeepEqual(finalTransfer(target, "translated").records, finalTransfer(target, "old-traffic").records); |
| 592 | 592 | 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 | ||
| 376 | 376 | requestTraffic(h, "IDFM:C100", anotherKey, KEY_B); |
| 377 | 377 | assert.equal(h.transport.instances.length, 3); |
| 378 | 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 | 380 | assert.equal(h.transport.instances[1].headers["Accept-Language"], "en"); |
| 381 | 381 | assert.equal(h.transport.instances[2].headers.apikey, KEY_B); |
| 382 | 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 | 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 | 642 | test("transport errors and malformed traffic are unavailable, not successful UNKNOWN, and are not cached", function () { |
| 456 | 643 | var h = harness(TRAFFIC_MS); |
| 457 | 644 | 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 | ||
| 401 | 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 | 405 | var payload = trafficFixture(); |
| 406 | 406 | payload.lines[1].impactedObjects[0].disruptionIds = ["delay-active", "blocking-unclassified"]; |
| 407 | + payload.disruptions[0].lastUpdate = "20260115T095900"; | |
| 407 | 408 | 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." | |
| 410 | 411 | }); |
| 411 | 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 | 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 | 548 | function (payload) { delete payload.disruptions; }, |
| 505 | 549 | function (payload) { payload.lines[1].impactedObjects[0].disruptionIds = ["missing"]; }, |
| 506 | 550 | function (payload) { payload.disruptions[0].severity = "MAJEURE"; }, |
| 551 | + function (payload) { payload.disruptions[0].lastUpdate = "20260230T100000"; }, | |
| 507 | 552 | function (payload) { payload.disruptions[0].applicationPeriods = [{ begin: "20260230T100000", end: "20260230T110000" }]; }, |
| 508 | 553 | function (payload) { payload.disruptions[0].applicationPeriods[0].begin += "\n"; }, |
| 509 | 554 | 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])); }, |