anthodev/lapin-futepublic Fork
⑂ 1380da0
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): accept up to 256 application periods per disruption

Cedric ANTHONY committed 2026-10-06T23:22:23+02:00 Browse files
1380da0 parent: b3222c8
modified CHANGELOG.md +11 -0
@@ -1,5 +1,16 @@
11 # Changelog
22
3+## [Unreleased]
4+
5+Traffic reporting now handles PRIM incident schedules containing up to 256 application periods, preventing longer valid notices from disabling traffic information for unrelated lines.
6+
7+Catalog activation now lets the atomic rename decide whether the candidate and active paths share a filesystem. This avoids false rejections when OverlayFS reports different devices for a file and its parent directory.
8+
9+### Fixes
10+
11+- fix(traffic): accept up to 256 application periods per disruption
12+- fix(catalog): use atomic rename to validate activation filesystem
13+
314 ## [1.2.1] - 2026-09-20
415
516 Catalog rebuilds and publication now run independently of settings-page deployment and watch releases.
@@ -1,5 +1,16 @@
1 # Changelog1 # Changelog
2 2
3+## [Unreleased]
4+
5+Traffic reporting now handles PRIM incident schedules containing up to 256 application periods, preventing longer valid notices from disabling traffic information for unrelated lines.
6+
7+Catalog activation now lets the atomic rename decide whether the candidate and active paths share a filesystem. This avoids false rejections when OverlayFS reports different devices for a file and its parent directory.
8+
9+### Fixes
10+
11+- fix(traffic): accept up to 256 application periods per disruption
12+- fix(catalog): use atomic rename to validate activation filesystem
13+
3 ## [1.2.1] - 2026-09-2014 ## [1.2.1] - 2026-09-20
4 15
5 Catalog rebuilds and publication now run independently of settings-page deployment and watch releases.16 Catalog rebuilds and publication now run independently of settings-page deployment and watch releases.
modified README.md +1 -1
@@ -43,7 +43,7 @@ Lapin Futé puts Île-de-France public-transport arrivals on a Pebble watch. The
4343
4444 - **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.
4545 - **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.
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. Active works participate in the same selection and can report stopped or delayed service; future, expired, and information-only notices remain excluded. Periods are reevaluated on existing request triggers, not by a new timer. 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.
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. Traffic normalization accepts up to 256 application periods per disruption; payloads exceeding this limit remain rejected. Active works participate in the same selection and can report stopped or delayed service; future, expired, and information-only notices remain excluded. Periods are reevaluated on existing request triggers, not by a new timer. 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.
4747 - **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.
4848 - **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 (`?`).
4949 - **English and French.** In settings, choose Automatic to follow the watch system language, or force Français or English for the watch app. Save applies the choice even when favorites are unchanged. The phone remembers it across restarts; reopening settings uses the effective language.
@@ -43,7 +43,7 @@ Lapin Futé puts Île-de-France public-transport arrivals on a Pebble watch. The
43 43
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.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.
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.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.
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. Active works participate in the same selection and can report stopped or delayed service; future, expired, and information-only notices remain excluded. Periods are reevaluated on existing request triggers, not by a new timer. 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.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. Traffic normalization accepts up to 256 application periods per disruption; payloads exceeding this limit remain rejected. Active works participate in the same selection and can report stopped or delayed service; future, expired, and information-only notices remain excluded. Periods are reevaluated on existing request triggers, not by a new timer. 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.
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.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.
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 (`?`).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 (`?`).
49 - **English and French.** In settings, choose Automatic to follow the watch system language, or force Français or English for the watch app. Save applies the choice even when favorites are unchanged. The phone remembers it across restarts; reopening settings uses the effective language.49 - **English and French.** In settings, choose Automatic to follow the watch system language, or force Français or English for the watch app. Save applies the choice even when favorites are unchanged. The phone remembers it across restarts; reopening settings uses the effective language.
modified packages/catalog/src/catalog-import.ts +3 -3
@@ -1727,9 +1727,6 @@ export function activateCatalogCandidate(candidatePath: string, activePath: stri
17271727 let result: CatalogBuildResult;
17281728 try {
17291729 result = validateCatalogCandidate(candidate);
1730- if (statSync(candidate).dev !== statSync(dirname(active)).dev) {
1731- fail("CROSS_DEVICE_ACTIVATION", "catalog candidate and active directory must share a filesystem");
1732- }
17331730 fsyncFile(candidate);
17341731 } catch (error) {
17351732 removeCandidateFiles(candidate);
@@ -1739,6 +1736,9 @@ export function activateCatalogCandidate(candidatePath: string, activePath: stri
17391736 renameSync(candidate, active);
17401737 } catch (error) {
17411738 removeCandidateFiles(candidate);
1739+ if ((error as NodeJS.ErrnoException).code === "EXDEV") {
1740+ fail("CROSS_DEVICE_ACTIVATION", "catalog candidate and active directory must share a filesystem", error);
1741+ }
17421742 throw error;
17431743 }
17441744 try {
@@ -1727,9 +1727,6 @@ export function activateCatalogCandidate(candidatePath: string, activePath: stri
1727 let result: CatalogBuildResult;1727 let result: CatalogBuildResult;
1728 try {1728 try {
1729 result = validateCatalogCandidate(candidate);1729 result = validateCatalogCandidate(candidate);
1730- if (statSync(candidate).dev !== statSync(dirname(active)).dev) {
1731- fail("CROSS_DEVICE_ACTIVATION", "catalog candidate and active directory must share a filesystem");
1732- }
1733 fsyncFile(candidate);1730 fsyncFile(candidate);
1734 } catch (error) {1731 } catch (error) {
1735 removeCandidateFiles(candidate);1732 removeCandidateFiles(candidate);
@@ -1739,6 +1736,9 @@ export function activateCatalogCandidate(candidatePath: string, activePath: stri
1739 renameSync(candidate, active);1736 renameSync(candidate, active);
1740 } catch (error) {1737 } catch (error) {
1741 removeCandidateFiles(candidate);1738 removeCandidateFiles(candidate);
1739+ if ((error as NodeJS.ErrnoException).code === "EXDEV") {
1740+ fail("CROSS_DEVICE_ACTIVATION", "catalog candidate and active directory must share a filesystem", error);
1741+ }
1742 throw error;1742 throw error;
1743 }1743 }
1744 try {1744 try {
modified packages/catalog/test/catalog-import.test.ts +72 -0
@@ -2,7 +2,9 @@ import test from "node:test";
22 import type { TestContext } from "node:test";
33 import assert from "node:assert/strict";
44 import { createHash } from "node:crypto";
5+import fs from "node:fs";
56 import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7+import { syncBuiltinESMExports } from "node:module";
68 import { tmpdir } from "node:os";
79 import { join } from "node:path";
810 import { DatabaseSync } from "node:sqlite";
@@ -15,6 +17,7 @@ import {
1517 activateCatalogCandidate,
1618 buildCatalogCandidate,
1719 buildCatalogCandidateFromRecords,
20+ CatalogImportError,
1821 createPlaceIdentity,
1922 createServiceIdentity,
2023 downloadCatalogSource,
@@ -488,6 +491,75 @@ test("import normalizes valid colors and rejects invalid or missing authoritativ
488491 assert.equal(existsSync(missingCandidate), false);
489492 });
490493
494+test("activation publishes a usable catalog when candidate and directory stat devices differ", async (t) => {
495+ const directory = workspace(t);
496+ const candidate = join(directory, "candidate.sqlite");
497+ const active = join(directory, "catalog.sqlite");
498+ await buildCatalogCandidateFromRecords({ candidatePath: candidate, sourceRevision: "fixture-2026-08-a", sources: sourcesFor("fixture-2026-08-a") });
499+
500+ const originalStatSync = fs.statSync;
501+ fs.statSync = ((...args: Parameters<typeof fs.statSync>) => {
502+ const stats = originalStatSync(...args);
503+ return args[0] === candidate ? Object.assign(stats, { dev: Number(stats.dev) + 1 }) : stats;
504+ }) as typeof fs.statSync;
505+ syncBuiltinESMExports();
506+ try {
507+ const result = activateCatalogCandidate(candidate, active);
508+ assert.equal(result.candidatePath, active);
509+ } finally {
510+ fs.statSync = originalStatSync;
511+ syncBuiltinESMExports();
512+ }
513+
514+ assert.equal(existsSync(candidate), false);
515+ const manager = new CatalogManager();
516+ manager.reload(active);
517+ service(manager, place(manager, "gare du nord", "BUS", "Paris").placeId, "Hôpital Européen");
518+});
519+
520+test("activation reports EXDEV and preserves the active catalog while cleaning candidate sidecars", async (t) => {
521+ const directory = workspace(t);
522+ const active = join(directory, "catalog.sqlite");
523+ const first = join(directory, "first.sqlite");
524+ await buildCatalogCandidateFromRecords({ candidatePath: first, sourceRevision: "fixture-2026-08-a", sources: sourcesFor("fixture-2026-08-a") });
525+ activateCatalogCandidate(first, active);
526+ const before = readFileSync(active);
527+
528+ const candidate = join(directory, "replacement.sqlite");
529+ await buildCatalogCandidateFromRecords({ candidatePath: candidate, sourceRevision: "fixture-2026-08-b", sources: sourcesFor("fixture-2026-08-b") });
530+ for (const suffix of ["-journal", "-wal", "-shm"]) writeFileSync(`${candidate}${suffix}`, "");
531+
532+ const originalRenameSync = fs.renameSync;
533+ const crossDeviceError = Object.assign(new Error("Cross-device link"), { code: "EXDEV" });
534+ fs.renameSync = ((oldPath: fs.PathLike, newPath: fs.PathLike) => {
535+ if (oldPath === candidate && newPath === active) throw crossDeviceError;
536+ return originalRenameSync(oldPath, newPath);
537+ }) as typeof fs.renameSync;
538+ syncBuiltinESMExports();
539+ try {
540+ assert.throws(
541+ () => activateCatalogCandidate(candidate, active),
542+ (error: unknown) => {
543+ assert.ok(error instanceof CatalogImportError);
544+ assert.equal(error.code, "CROSS_DEVICE_ACTIVATION");
545+ assert.equal(error.cause, crossDeviceError);
546+ return true;
547+ },
548+ );
549+ } finally {
550+ fs.renameSync = originalRenameSync;
551+ syncBuiltinESMExports();
552+ }
553+
554+ assert.deepEqual(readFileSync(active), before);
555+ for (const path of [candidate, `${candidate}-journal`, `${candidate}-wal`, `${candidate}-shm`]) {
556+ assert.equal(existsSync(path), false);
557+ }
558+ const manager = new CatalogManager();
559+ manager.reload(active);
560+ service(manager, place(manager, "gare du nord", "BUS", "Paris").placeId, "Hôpital Européen");
561+});
562+
491563 test("activation preserves stable services, removes stale IDs, and failed replacement leaves the active file untouched", async (t) => {
492564 const directory = workspace(t);
493565 const active = join(directory, "catalog.sqlite");
@@ -2,7 +2,9 @@ import test from "node:test";
2 import type { TestContext } from "node:test";2 import type { TestContext } from "node:test";
3 import assert from "node:assert/strict";3 import assert from "node:assert/strict";
4 import { createHash } from "node:crypto";4 import { createHash } from "node:crypto";
5+import fs from "node:fs";
5 import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";6 import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7+import { syncBuiltinESMExports } from "node:module";
6 import { tmpdir } from "node:os";8 import { tmpdir } from "node:os";
7 import { join } from "node:path";9 import { join } from "node:path";
8 import { DatabaseSync } from "node:sqlite";10 import { DatabaseSync } from "node:sqlite";
@@ -15,6 +17,7 @@ import {
15 activateCatalogCandidate,17 activateCatalogCandidate,
16 buildCatalogCandidate,18 buildCatalogCandidate,
17 buildCatalogCandidateFromRecords,19 buildCatalogCandidateFromRecords,
20+ CatalogImportError,
18 createPlaceIdentity,21 createPlaceIdentity,
19 createServiceIdentity,22 createServiceIdentity,
20 downloadCatalogSource,23 downloadCatalogSource,
@@ -488,6 +491,75 @@ test("import normalizes valid colors and rejects invalid or missing authoritativ
488 assert.equal(existsSync(missingCandidate), false);491 assert.equal(existsSync(missingCandidate), false);
489 });492 });
490 493
494+test("activation publishes a usable catalog when candidate and directory stat devices differ", async (t) => {
495+ const directory = workspace(t);
496+ const candidate = join(directory, "candidate.sqlite");
497+ const active = join(directory, "catalog.sqlite");
498+ await buildCatalogCandidateFromRecords({ candidatePath: candidate, sourceRevision: "fixture-2026-08-a", sources: sourcesFor("fixture-2026-08-a") });
499+
500+ const originalStatSync = fs.statSync;
501+ fs.statSync = ((...args: Parameters<typeof fs.statSync>) => {
502+ const stats = originalStatSync(...args);
503+ return args[0] === candidate ? Object.assign(stats, { dev: Number(stats.dev) + 1 }) : stats;
504+ }) as typeof fs.statSync;
505+ syncBuiltinESMExports();
506+ try {
507+ const result = activateCatalogCandidate(candidate, active);
508+ assert.equal(result.candidatePath, active);
509+ } finally {
510+ fs.statSync = originalStatSync;
511+ syncBuiltinESMExports();
512+ }
513+
514+ assert.equal(existsSync(candidate), false);
515+ const manager = new CatalogManager();
516+ manager.reload(active);
517+ service(manager, place(manager, "gare du nord", "BUS", "Paris").placeId, "Hôpital Européen");
518+});
519+
520+test("activation reports EXDEV and preserves the active catalog while cleaning candidate sidecars", async (t) => {
521+ const directory = workspace(t);
522+ const active = join(directory, "catalog.sqlite");
523+ const first = join(directory, "first.sqlite");
524+ await buildCatalogCandidateFromRecords({ candidatePath: first, sourceRevision: "fixture-2026-08-a", sources: sourcesFor("fixture-2026-08-a") });
525+ activateCatalogCandidate(first, active);
526+ const before = readFileSync(active);
527+
528+ const candidate = join(directory, "replacement.sqlite");
529+ await buildCatalogCandidateFromRecords({ candidatePath: candidate, sourceRevision: "fixture-2026-08-b", sources: sourcesFor("fixture-2026-08-b") });
530+ for (const suffix of ["-journal", "-wal", "-shm"]) writeFileSync(`${candidate}${suffix}`, "");
531+
532+ const originalRenameSync = fs.renameSync;
533+ const crossDeviceError = Object.assign(new Error("Cross-device link"), { code: "EXDEV" });
534+ fs.renameSync = ((oldPath: fs.PathLike, newPath: fs.PathLike) => {
535+ if (oldPath === candidate && newPath === active) throw crossDeviceError;
536+ return originalRenameSync(oldPath, newPath);
537+ }) as typeof fs.renameSync;
538+ syncBuiltinESMExports();
539+ try {
540+ assert.throws(
541+ () => activateCatalogCandidate(candidate, active),
542+ (error: unknown) => {
543+ assert.ok(error instanceof CatalogImportError);
544+ assert.equal(error.code, "CROSS_DEVICE_ACTIVATION");
545+ assert.equal(error.cause, crossDeviceError);
546+ return true;
547+ },
548+ );
549+ } finally {
550+ fs.renameSync = originalRenameSync;
551+ syncBuiltinESMExports();
552+ }
553+
554+ assert.deepEqual(readFileSync(active), before);
555+ for (const path of [candidate, `${candidate}-journal`, `${candidate}-wal`, `${candidate}-shm`]) {
556+ assert.equal(existsSync(path), false);
557+ }
558+ const manager = new CatalogManager();
559+ manager.reload(active);
560+ service(manager, place(manager, "gare du nord", "BUS", "Paris").placeId, "Hôpital Européen");
561+});
562+
491 test("activation preserves stable services, removes stale IDs, and failed replacement leaves the active file untouched", async (t) => {563 test("activation preserves stable services, removes stale IDs, and failed replacement leaves the active file untouched", async (t) => {
492 const directory = workspace(t);564 const directory = workspace(t);
493 const active = join(directory, "catalog.sqlite");565 const active = join(directory, "catalog.sqlite");
modified packages/companion/src/prim-traffic.js +1 -1
@@ -12,7 +12,7 @@ var NAMED_ENTITIES = { amp: "&", apos: "'", gt: ">", lt: "<", nbsp: " ", quot: "
1212 var hasOwn = Object.prototype.hasOwnProperty;
1313 var MAX_DISRUPTIONS = 4096;
1414 var MAX_LINES = 4096;
15-var MAX_PERIODS_PER_DISRUPTION = 128;
15+var MAX_PERIODS_PER_DISRUPTION = 256;
1616 var MAX_IMPACTED_OBJECTS = 16384;
1717 var MAX_DISRUPTION_REFERENCES = 32768;
1818
@@ -12,7 +12,7 @@ var NAMED_ENTITIES = { amp: "&", apos: "'", gt: ">", lt: "<", nbsp: " ", quot: "
12 var hasOwn = Object.prototype.hasOwnProperty;12 var hasOwn = Object.prototype.hasOwnProperty;
13 var MAX_DISRUPTIONS = 4096;13 var MAX_DISRUPTIONS = 4096;
14 var MAX_LINES = 4096;14 var MAX_LINES = 4096;
15-var MAX_PERIODS_PER_DISRUPTION = 128;15+var MAX_PERIODS_PER_DISRUPTION = 256;
16 var MAX_IMPACTED_OBJECTS = 16384;16 var MAX_IMPACTED_OBJECTS = 16384;
17 var MAX_DISRUPTION_REFERENCES = 32768;17 var MAX_DISRUPTION_REFERENCES = 32768;
18 18
modified packages/companion/test/prim-traffic-empty-periods.test.js +60 -0
@@ -95,6 +95,66 @@ test("a bulk body containing empty periods succeeds through the production clien
9595 } }]);
9696 });
9797
98+// One local (Paris) daily period, 11:00:00-12:00:00; index count-1 is the day of INSTANT.
99+function dailyPeriods(count) {
100+ var periods = [];
101+ for (var index = 0; index < count; index += 1) {
102+ var day = new Date(INSTANT - (count - 1 - index) * 86400000 + 2 * 3600000);
103+ var prefix = day.getUTCFullYear() + String(day.getUTCMonth() + 1).padStart(2, "0") +
104+ String(day.getUTCDate()).padStart(2, "0");
105+ periods.push({ begin: prefix + "T110000", end: prefix + "T120000" });
106+ }
107+ return periods;
108+}
109+
110+function longHistoryFixture() {
111+ var source = fixture();
112+ source.disruptions[0].applicationPeriods = dailyPeriods(256);
113+ return source;
114+}
115+
116+test("256 daily periods keep the blocking incident usable with only the current day active", function () {
117+ var envelope = traffic.normalizePrimTrafficResponse(longHistoryFixture(), KEY);
118+ assert.equal(stateAt(envelope, "line:IDFM:C100", INSTANT), "NORMAL");
119+ assert.equal(stateAt(envelope, "line:IDFM:C200", INSTANT), "STOPPED");
120+ var detail = traffic.trafficForLine(envelope, "line:IDFM:C200", INSTANT, INSTANT);
121+ assert.equal(detail.title, "Modification de circulation");
122+ assert.equal(detail.text, "Consultez les horaires de la ligne.");
123+});
124+
125+test("257 periods exceed the payload bound and reject the whole body", function () {
126+ var source = fixture();
127+ source.disruptions[0].applicationPeriods = dailyPeriods(257);
128+ assert.throws(function () { traffic.normalizePrimTrafficResponse(source, KEY); },
129+ /Invalid PRIM traffic payload/);
130+});
131+
132+test("a bulk body with a 256-period notice still succeeds through the production client", function () {
133+ var source = longHistoryFixture();
134+ var bytes = Buffer.from(JSON.stringify(source));
135+ var clock = new fakes.FakeClock(INSTANT);
136+ var outcomes = [];
137+ function RecordedXHR() {}
138+ RecordedXHR.prototype.open = function (method, url) {
139+ assert.equal(method, "GET");
140+ assert.equal(url, "https://prim.iledefrance-mobilites.fr/marketplace/disruptions_bulk/disruptions/v2");
141+ };
142+ RecordedXHR.prototype.setRequestHeader = function () {};
143+ RecordedXHR.prototype.send = function () {
144+ this.status = 200;
145+ this.response = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
146+ this.onload();
147+ };
148+ var client = createPrimClient({ XHR: RecordedXHR, clock: clock });
149+ client.traffic({ lineRef: "STIF:Line::C100:", language: "fr", apiKey: KEY }, function (outcome) {
150+ outcomes.push(outcome);
151+ });
152+ clock.advance(0);
153+ assert.deepEqual(outcomes, [{ status: "AVAILABLE", data: {
154+ schemaVersion: 1, state: "NORMAL", checkedAt: INSTANT / 1000
155+ } }]);
156+});
157+
98158 test("reversed or invalid traffic periods still reject instead of becoming empty or invented ranges", function () {
99159 var cases = [
100160 { begin: "20260906T112501", end: "20260906T112500" },
@@ -95,6 +95,66 @@ test("a bulk body containing empty periods succeeds through the production clien
95 } }]);95 } }]);
96 });96 });
97 97
98+// One local (Paris) daily period, 11:00:00-12:00:00; index count-1 is the day of INSTANT.
99+function dailyPeriods(count) {
100+ var periods = [];
101+ for (var index = 0; index < count; index += 1) {
102+ var day = new Date(INSTANT - (count - 1 - index) * 86400000 + 2 * 3600000);
103+ var prefix = day.getUTCFullYear() + String(day.getUTCMonth() + 1).padStart(2, "0") +
104+ String(day.getUTCDate()).padStart(2, "0");
105+ periods.push({ begin: prefix + "T110000", end: prefix + "T120000" });
106+ }
107+ return periods;
108+}
109+
110+function longHistoryFixture() {
111+ var source = fixture();
112+ source.disruptions[0].applicationPeriods = dailyPeriods(256);
113+ return source;
114+}
115+
116+test("256 daily periods keep the blocking incident usable with only the current day active", function () {
117+ var envelope = traffic.normalizePrimTrafficResponse(longHistoryFixture(), KEY);
118+ assert.equal(stateAt(envelope, "line:IDFM:C100", INSTANT), "NORMAL");
119+ assert.equal(stateAt(envelope, "line:IDFM:C200", INSTANT), "STOPPED");
120+ var detail = traffic.trafficForLine(envelope, "line:IDFM:C200", INSTANT, INSTANT);
121+ assert.equal(detail.title, "Modification de circulation");
122+ assert.equal(detail.text, "Consultez les horaires de la ligne.");
123+});
124+
125+test("257 periods exceed the payload bound and reject the whole body", function () {
126+ var source = fixture();
127+ source.disruptions[0].applicationPeriods = dailyPeriods(257);
128+ assert.throws(function () { traffic.normalizePrimTrafficResponse(source, KEY); },
129+ /Invalid PRIM traffic payload/);
130+});
131+
132+test("a bulk body with a 256-period notice still succeeds through the production client", function () {
133+ var source = longHistoryFixture();
134+ var bytes = Buffer.from(JSON.stringify(source));
135+ var clock = new fakes.FakeClock(INSTANT);
136+ var outcomes = [];
137+ function RecordedXHR() {}
138+ RecordedXHR.prototype.open = function (method, url) {
139+ assert.equal(method, "GET");
140+ assert.equal(url, "https://prim.iledefrance-mobilites.fr/marketplace/disruptions_bulk/disruptions/v2");
141+ };
142+ RecordedXHR.prototype.setRequestHeader = function () {};
143+ RecordedXHR.prototype.send = function () {
144+ this.status = 200;
145+ this.response = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
146+ this.onload();
147+ };
148+ var client = createPrimClient({ XHR: RecordedXHR, clock: clock });
149+ client.traffic({ lineRef: "STIF:Line::C100:", language: "fr", apiKey: KEY }, function (outcome) {
150+ outcomes.push(outcome);
151+ });
152+ clock.advance(0);
153+ assert.deepEqual(outcomes, [{ status: "AVAILABLE", data: {
154+ schemaVersion: 1, state: "NORMAL", checkedAt: INSTANT / 1000
155+ } }]);
156+});
157+
98 test("reversed or invalid traffic periods still reject instead of becoming empty or invented ranges", function () {158 test("reversed or invalid traffic periods still reject instead of becoming empty or invented ranges", function () {
99 var cases = [159 var cases = [
100 { begin: "20260906T112501", end: "20260906T112500" },160 { begin: "20260906T112501", end: "20260906T112500" },