Merge 31/fix/traffic/accept-256-application-periods into develop
2750a09 parent: b3222c8 modified
CHANGELOG.md +11 -0 | @@ -1,5 +1,16 @@ | ||
| 1 | 1 | # 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 | 14 | ## [1.2.1] - 2026-09-20 |
| 4 | 15 | |
| 5 | 16 | Catalog rebuilds and publication now run independently of settings-page deployment and watch releases. |
| @@ -1,5 +1,16 @@ | |||
| 1 | # Changelog | 1 | # 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-20 | 14 | ## [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 | ||
| 43 | 43 | |
| 44 | 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 | 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 | 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 | 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 | 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. |
| @@ -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 | ||
| 1727 | 1727 | let result: CatalogBuildResult; |
| 1728 | 1728 | try { |
| 1729 | 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 | 1730 | fsyncFile(candidate); |
| 1734 | 1731 | } catch (error) { |
| 1735 | 1732 | removeCandidateFiles(candidate); |
| @@ -1739,6 +1736,9 @@ export function activateCatalogCandidate(candidatePath: string, activePath: stri | ||
| 1739 | 1736 | renameSync(candidate, active); |
| 1740 | 1737 | } catch (error) { |
| 1741 | 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 | 1742 | throw error; |
| 1743 | 1743 | } |
| 1744 | 1744 | 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"; | ||
| 2 | 2 | import type { TestContext } from "node:test"; |
| 3 | 3 | import assert from "node:assert/strict"; |
| 4 | 4 | import { createHash } from "node:crypto"; |
| 5 | +import fs from "node:fs"; | |
| 5 | 6 | import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; |
| 7 | +import { syncBuiltinESMExports } from "node:module"; | |
| 6 | 8 | import { tmpdir } from "node:os"; |
| 7 | 9 | import { join } from "node:path"; |
| 8 | 10 | import { DatabaseSync } from "node:sqlite"; |
| @@ -15,6 +17,7 @@ import { | ||
| 15 | 17 | activateCatalogCandidate, |
| 16 | 18 | buildCatalogCandidate, |
| 17 | 19 | buildCatalogCandidateFromRecords, |
| 20 | + CatalogImportError, | |
| 18 | 21 | createPlaceIdentity, |
| 19 | 22 | createServiceIdentity, |
| 20 | 23 | downloadCatalogSource, |
| @@ -488,6 +491,75 @@ test("import normalizes valid colors and rejects invalid or missing authoritativ | ||
| 488 | 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 | 563 | test("activation preserves stable services, removes stale IDs, and failed replacement leaves the active file untouched", async (t) => { |
| 492 | 564 | const directory = workspace(t); |
| 493 | 565 | 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: " | ||
| 12 | 12 | var hasOwn = Object.prototype.hasOwnProperty; |
| 13 | 13 | var MAX_DISRUPTIONS = 4096; |
| 14 | 14 | var MAX_LINES = 4096; |
| 15 | -var MAX_PERIODS_PER_DISRUPTION = 128; | |
| 15 | +var MAX_PERIODS_PER_DISRUPTION = 256; | |
| 16 | 16 | var MAX_IMPACTED_OBJECTS = 16384; |
| 17 | 17 | var MAX_DISRUPTION_REFERENCES = 32768; |
| 18 | 18 | |
| @@ -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 | ||
| 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 | 158 | test("reversed or invalid traffic periods still reject instead of becoming empty or invented ranges", function () { |
| 99 | 159 | var cases = [ |
| 100 | 160 | { 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" }, |