feat(configuration): add persistent watch language selection (#30) (#31)Unverified
1650944 parent: 7aaa70b modified
CHANGELOG.md +12 -0 | @@ -1,5 +1,17 @@ | ||
| 1 | 1 | # Changelog |
| 2 | 2 | |
| 3 | +## [Unreleased] | |
| 4 | + | |
| 5 | +The configuration page can now override the watch app language independently of the watch system settings. Automatic remains the default; explicit French or English choices are saved on the phone and survive restarts. | |
| 6 | + | |
| 7 | +### Features | |
| 8 | + | |
| 9 | +- Add an accessible Automatic / Français / English selector and synchronize language-only changes without editing favorites (#30). | |
| 10 | + | |
| 11 | +### Fixes | |
| 12 | + | |
| 13 | +- Keep the previous language and favorites active when a replacement credential's configuration cannot be saved. | |
| 14 | + | |
| 3 | 15 | ## [1.1.0] - 2026-09-19 |
| 4 | 16 | |
| 5 | 17 | Favorites now pair a departure stop and line with a reachable arrival stop, including intermediate stops. Available across all five transport modes, journeys stay on one line without connections. The configuration page lets you select the arrival stop. The watch combines full-line and short-turn departures chronologically, excludes services known not to reach your arrival stop, and marks uncertain journeys with `?` without changing their departure times. |
| @@ -1,5 +1,17 @@ | |||
| 1 | # Changelog | 1 | # Changelog |
| 2 | 2 | ||
| 3 | +## [Unreleased] | ||
| 4 | + | ||
| 5 | +The configuration page can now override the watch app language independently of the watch system settings. Automatic remains the default; explicit French or English choices are saved on the phone and survive restarts. | ||
| 6 | + | ||
| 7 | +### Features | ||
| 8 | + | ||
| 9 | +- Add an accessible Automatic / Français / English selector and synchronize language-only changes without editing favorites (#30). | ||
| 10 | + | ||
| 11 | +### Fixes | ||
| 12 | + | ||
| 13 | +- Keep the previous language and favorites active when a replacement credential's configuration cannot be saved. | ||
| 14 | + | ||
| 3 | ## [1.1.0] - 2026-09-19 | 15 | ## [1.1.0] - 2026-09-19 |
| 4 | 16 | ||
| 5 | Favorites now pair a departure stop and line with a reachable arrival stop, including intermediate stops. Available across all five transport modes, journeys stay on one line without connections. The configuration page lets you select the arrival stop. The watch combines full-line and short-turn departures chronologically, excludes services known not to reach your arrival stop, and marks uncertain journeys with `?` without changing their departure times. | 17 | Favorites now pair a departure stop and line with a reachable arrival stop, including intermediate stops. Available across all five transport modes, journeys stay on one line without connections. The configuration page lets you select the arrival stop. The watch combines full-line and short-turn departures chronologically, excludes services known not to reach your arrival stop, and marks uncertain journeys with `?` without changing their departure times. |
modified
README.md +1 -1 | @@ -46,7 +46,7 @@ Lapin Futé puts Île-de-France public-transport arrivals on a Pebble watch. The | ||
| 46 | 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. |
| 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 | -- **English and French.** The watch interface and the settings page are both bilingual. | |
| 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. | |
| 50 | 50 | - **Settings that fit your phone.** Light and dark themes follow your system preference. Collapsible sections keep favorite editing, PRIM access, and watch synchronization easy to reach. |
| 51 | 51 | |
| 52 | 52 | ## Compatibility |
| @@ -46,7 +46,7 @@ Lapin Futé puts Île-de-France public-transport arrivals on a Pebble watch. The | |||
| 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. | 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. |
| 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.** The watch interface and the settings page are both bilingual. | 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. |
| 50 | - **Settings that fit your phone.** Light and dark themes follow your system preference. Collapsible sections keep favorite editing, PRIM access, and watch synchronization easy to reach. | 50 | - **Settings that fit your phone.** Light and dark themes follow your system preference. Collapsible sections keep favorite editing, PRIM access, and watch synchronization easy to reach. |
| 51 | 51 | ||
| 52 | ## Compatibility | 52 | ## Compatibility |
modified
packages/companion/src/configuration.js +44 -13 | @@ -2,11 +2,13 @@ | ||
| 2 | 2 | |
| 3 | 3 | var contracts = require("./contracts"); |
| 4 | 4 | var CONFIG_STORAGE_KEY = "lapinFuteConfig"; |
| 5 | +// Phone-local preference version; page envelopes remain CONFIGURATION_VERSION 2. | |
| 6 | +var CONFIG_SCHEMA_VERSION = 3; | |
| 5 | 7 | var RESULTS_STORAGE_KEY = "lapinFuteResults"; |
| 6 | 8 | // Phone-local cache version. Earlier caches did not bind results to an arrival. |
| 7 | 9 | var CACHE_SCHEMA_VERSION = 3; |
| 8 | 10 | var INVALID_KEY_STATUS_STORAGE_KEY = "lapinFuteInvalidKeyStatus"; |
| 9 | -var CONFIG_RECORD_KEYS = ["schemaVersion", "favorites", "primApiKey", "keyStatus"]; | |
| 11 | +var CONFIG_RECORD_KEYS = ["schemaVersion", "favorites", "primApiKey", "keyStatus", "languagePreference"]; | |
| 10 | 12 | var CACHE_RECORD_KEYS = ["schemaVersion", "overview", "trafficDetails"]; |
| 11 | 13 | var OVERVIEW_ENTRY_KEYS = [ |
| 12 | 14 | "favoriteId", |
| @@ -24,7 +26,7 @@ var INVALID_KEY_STATUS_RECORD_KEYS = [ | ||
| 24 | 26 | "keyStatus", |
| 25 | 27 | "configurationFingerprint" |
| 26 | 28 | ]; |
| 27 | -var UPDATE_KEYS = ["schemaVersion", "favorites", "apiKeyUpdate", "forceFullSync"]; | |
| 29 | +var UPDATE_KEYS = ["schemaVersion", "favorites", "apiKeyUpdate", "forceFullSync", "languagePreference"]; | |
| 28 | 30 | var MAX_CLOSE_RESPONSE_LENGTH = 32768; |
| 29 | 31 | var MAX_SAFE_INTEGER = 9007199254740991; |
| 30 | 32 | var FAVORITE_STRING_KEYS = [ |
| @@ -47,19 +49,21 @@ var ROUTING_STRING_KEYS = [ | ||
| 47 | 49 | |
| 48 | 50 | function emptyConfiguration() { |
| 49 | 51 | return { |
| 50 | - schemaVersion: contracts.CONFIGURATION_VERSION, | |
| 52 | + schemaVersion: CONFIG_SCHEMA_VERSION, | |
| 51 | 53 | favorites: [], |
| 52 | 54 | primApiKey: null, |
| 53 | - keyStatus: contracts.KEY_STATUS.MISSING | |
| 55 | + keyStatus: contracts.KEY_STATUS.MISSING, | |
| 56 | + languagePreference: "auto" | |
| 54 | 57 | }; |
| 55 | 58 | } |
| 56 | 59 | |
| 57 | 60 | function copyConfiguration(value) { |
| 58 | 61 | return { |
| 59 | - schemaVersion: contracts.CONFIGURATION_VERSION, | |
| 62 | + schemaVersion: CONFIG_SCHEMA_VERSION, | |
| 60 | 63 | favorites: value.favorites.map(contracts.copyPhoneFavorite), |
| 61 | 64 | primApiKey: value.primApiKey, |
| 62 | - keyStatus: value.keyStatus | |
| 65 | + keyStatus: value.keyStatus, | |
| 66 | + languagePreference: value.languagePreference | |
| 63 | 67 | }; |
| 64 | 68 | } |
| 65 | 69 | |
| @@ -162,10 +166,20 @@ function activeWatchLanguage(Pebble) { | ||
| 162 | 166 | } |
| 163 | 167 | } |
| 164 | 168 | |
| 169 | +function isLanguagePreference(value) { | |
| 170 | + return value === "auto" || value === contracts.WIRE_LANGUAGE.EN || value === contracts.WIRE_LANGUAGE.FR; | |
| 171 | +} | |
| 172 | + | |
| 173 | +function effectiveWatchLanguage(Pebble, preference) { | |
| 174 | + return preference === "auto" ? activeWatchLanguage(Pebble) : preference; | |
| 175 | +} | |
| 176 | + | |
| 165 | 177 | function isStoredConfiguration(value) { |
| 166 | 178 | if (!contracts.isObject(value) |
| 167 | 179 | || !contracts.hasOnlyKeys(value, CONFIG_RECORD_KEYS) |
| 168 | - || value.schemaVersion !== contracts.CONFIGURATION_VERSION | |
| 180 | + || value.schemaVersion !== CONFIG_SCHEMA_VERSION | |
| 181 | + || !Object.prototype.hasOwnProperty.call(value, "languagePreference") | |
| 182 | + || !isLanguagePreference(value.languagePreference) | |
| 169 | 183 | || !contracts.isPhoneFavoriteList(value.favorites)) return false; |
| 170 | 184 | if (value.primApiKey === null) { |
| 171 | 185 | return value.keyStatus === contracts.KEY_STATUS.MISSING; |
| @@ -179,8 +193,13 @@ function isStoredConfiguration(value) { | ||
| 179 | 193 | function recoverStoredConfiguration(value) { |
| 180 | 194 | if (!contracts.isObject(value) || !Array.isArray(value.favorites) |
| 181 | 195 | || value.schemaVersion !== contracts.SCHEMA_VERSION |
| 182 | - && value.schemaVersion !== contracts.CONFIGURATION_VERSION) return null; | |
| 196 | + && value.schemaVersion !== contracts.CONFIGURATION_VERSION | |
| 197 | + && value.schemaVersion !== CONFIG_SCHEMA_VERSION) return null; | |
| 183 | 198 | var legacy = value.schemaVersion === contracts.SCHEMA_VERSION; |
| 199 | + if (value.schemaVersion !== CONFIG_SCHEMA_VERSION | |
| 200 | + && !Object.prototype.hasOwnProperty.call(value, "languagePreference")) { | |
| 201 | + value.languagePreference = "auto"; | |
| 202 | + } | |
| 184 | 203 | // This freshly parsed record is private to restoration. Routing is replaceable |
| 185 | 204 | // catalog enrichment; preserve every other field for ordinary validation. |
| 186 | 205 | value.favorites.forEach(function (favorite) { |
| @@ -192,7 +211,7 @@ function recoverStoredConfiguration(value) { | ||
| 192 | 211 | && Object.prototype.hasOwnProperty.call(favorite, "routing") |
| 193 | 212 | && !contracts.isServiceRouting(favorite.routing)) delete favorite.routing; |
| 194 | 213 | }); |
| 195 | - value.schemaVersion = contracts.CONFIGURATION_VERSION; | |
| 214 | + value.schemaVersion = CONFIG_SCHEMA_VERSION; | |
| 196 | 215 | return isStoredConfiguration(value) ? value : null; |
| 197 | 216 | } |
| 198 | 217 | |
| @@ -202,6 +221,8 @@ function isConfigurationUpdate(value) { | ||
| 202 | 221 | && value.schemaVersion === contracts.CONFIGURATION_VERSION |
| 203 | 222 | && contracts.isPhoneFavoriteList(value.favorites) |
| 204 | 223 | && contracts.isApiKeyUpdate(value.apiKeyUpdate) |
| 224 | + && (!Object.prototype.hasOwnProperty.call(value, "languagePreference") | |
| 225 | + || isLanguagePreference(value.languagePreference)) | |
| 205 | 226 | && (!Object.prototype.hasOwnProperty.call(value, "forceFullSync") |
| 206 | 227 | || typeof value.forceFullSync === "boolean"); |
| 207 | 228 | } |
| @@ -460,7 +481,7 @@ function loadConfiguration(storage) { | ||
| 460 | 481 | var current = parseStored(storage, CONFIG_STORAGE_KEY); |
| 461 | 482 | var invalidMarker = parseStored(storage, INVALID_KEY_STATUS_STORAGE_KEY).value; |
| 462 | 483 | var loaded; |
| 463 | - var migration = current.value && current.value.schemaVersion === contracts.SCHEMA_VERSION; | |
| 484 | + var migration = current.value && current.value.schemaVersion !== CONFIG_SCHEMA_VERSION; | |
| 464 | 485 | if (!current.present) return emptyConfiguration(); |
| 465 | 486 | loaded = recoverStoredConfiguration(current.value); |
| 466 | 487 | if (loaded === null) return null; |
| @@ -695,16 +716,19 @@ function applyConfigurationUpdate(current, update) { | ||
| 695 | 716 | keyStatus = contracts.KEY_STATUS.MISSING; |
| 696 | 717 | } |
| 697 | 718 | return { |
| 698 | - schemaVersion: contracts.CONFIGURATION_VERSION, | |
| 719 | + schemaVersion: CONFIG_SCHEMA_VERSION, | |
| 699 | 720 | favorites: update.favorites.map(contracts.copyPhoneFavorite), |
| 700 | 721 | primApiKey: primApiKey, |
| 701 | - keyStatus: keyStatus | |
| 722 | + keyStatus: keyStatus, | |
| 723 | + languagePreference: Object.prototype.hasOwnProperty.call(update, "languagePreference") | |
| 724 | + ? update.languagePreference : current.languagePreference | |
| 702 | 725 | }; |
| 703 | 726 | } |
| 704 | 727 | |
| 705 | 728 | function parseCloseFragment(response) { |
| 706 | 729 | var fragment; |
| 707 | 730 | var parsed; |
| 731 | + var update; | |
| 708 | 732 | var closePrefix = "pebblejs://close#"; |
| 709 | 733 | if (typeof response !== "string" |
| 710 | 734 | || response.length === 0 |
| @@ -725,7 +749,7 @@ function parseCloseFragment(response) { | ||
| 725 | 749 | } |
| 726 | 750 | } |
| 727 | 751 | if (!isConfigurationUpdate(parsed)) return null; |
| 728 | - return { | |
| 752 | + update = { | |
| 729 | 753 | schemaVersion: contracts.CONFIGURATION_VERSION, |
| 730 | 754 | favorites: parsed.favorites.map(contracts.copyPhoneFavorite), |
| 731 | 755 | apiKeyUpdate: parsed.apiKeyUpdate.action === "REPLACE" |
| @@ -733,6 +757,10 @@ function parseCloseFragment(response) { | ||
| 733 | 757 | : { schemaVersion: contracts.SCHEMA_VERSION, action: parsed.apiKeyUpdate.action }, |
| 734 | 758 | forceFullSync: parsed.forceFullSync === true |
| 735 | 759 | }; |
| 760 | + if (Object.prototype.hasOwnProperty.call(parsed, "languagePreference")) { | |
| 761 | + update.languagePreference = parsed.languagePreference; | |
| 762 | + } | |
| 763 | + return update; | |
| 736 | 764 | } |
| 737 | 765 | |
| 738 | 766 | function configurationPageState(value, language) { |
| @@ -740,6 +768,7 @@ function configurationPageState(value, language) { | ||
| 740 | 768 | schemaVersion: contracts.CONFIGURATION_VERSION, |
| 741 | 769 | hasKey: value.primApiKey !== null, |
| 742 | 770 | favorites: value.favorites.map(contracts.copyPhoneFavorite), |
| 771 | + languagePreference: value.languagePreference, | |
| 743 | 772 | language: typeof language === "string" && language.length > 0 ? language : "en" |
| 744 | 773 | }; |
| 745 | 774 | } |
| @@ -764,6 +793,7 @@ function configurationUrl(baseUrl, value, language) { | ||
| 764 | 793 | |
| 765 | 794 | module.exports = { |
| 766 | 795 | CONFIG_STORAGE_KEY: CONFIG_STORAGE_KEY, |
| 796 | + CONFIG_SCHEMA_VERSION: CONFIG_SCHEMA_VERSION, | |
| 767 | 797 | RESULTS_STORAGE_KEY: RESULTS_STORAGE_KEY, |
| 768 | 798 | INVALID_KEY_STATUS_STORAGE_KEY: INVALID_KEY_STATUS_STORAGE_KEY, |
| 769 | 799 | MAX_CLOSE_RESPONSE_LENGTH: MAX_CLOSE_RESPONSE_LENGTH, |
| @@ -776,6 +806,7 @@ module.exports = { | ||
| 776 | 806 | areFavoritesSecretFree: areFavoritesSecretFree, |
| 777 | 807 | normalizeLanguage: normalizeLanguage, |
| 778 | 808 | activeWatchLanguage: activeWatchLanguage, |
| 809 | + effectiveWatchLanguage: effectiveWatchLanguage, | |
| 779 | 810 | loadConfiguration: loadConfiguration, |
| 780 | 811 | saveConfiguration: saveConfiguration, |
| 781 | 812 | saveInvalidConfiguration: saveInvalidConfiguration, |
| @@ -2,11 +2,13 @@ | |||
| 2 | 2 | ||
| 3 | var contracts = require("./contracts"); | 3 | var contracts = require("./contracts"); |
| 4 | var CONFIG_STORAGE_KEY = "lapinFuteConfig"; | 4 | var CONFIG_STORAGE_KEY = "lapinFuteConfig"; |
| 5 | +// Phone-local preference version; page envelopes remain CONFIGURATION_VERSION 2. | ||
| 6 | +var CONFIG_SCHEMA_VERSION = 3; | ||
| 5 | var RESULTS_STORAGE_KEY = "lapinFuteResults"; | 7 | var RESULTS_STORAGE_KEY = "lapinFuteResults"; |
| 6 | // Phone-local cache version. Earlier caches did not bind results to an arrival. | 8 | // Phone-local cache version. Earlier caches did not bind results to an arrival. |
| 7 | var CACHE_SCHEMA_VERSION = 3; | 9 | var CACHE_SCHEMA_VERSION = 3; |
| 8 | var INVALID_KEY_STATUS_STORAGE_KEY = "lapinFuteInvalidKeyStatus"; | 10 | var INVALID_KEY_STATUS_STORAGE_KEY = "lapinFuteInvalidKeyStatus"; |
| 9 | -var CONFIG_RECORD_KEYS = ["schemaVersion", "favorites", "primApiKey", "keyStatus"]; | 11 | +var CONFIG_RECORD_KEYS = ["schemaVersion", "favorites", "primApiKey", "keyStatus", "languagePreference"]; |
| 10 | var CACHE_RECORD_KEYS = ["schemaVersion", "overview", "trafficDetails"]; | 12 | var CACHE_RECORD_KEYS = ["schemaVersion", "overview", "trafficDetails"]; |
| 11 | var OVERVIEW_ENTRY_KEYS = [ | 13 | var OVERVIEW_ENTRY_KEYS = [ |
| 12 | "favoriteId", | 14 | "favoriteId", |
| @@ -24,7 +26,7 @@ var INVALID_KEY_STATUS_RECORD_KEYS = [ | |||
| 24 | "keyStatus", | 26 | "keyStatus", |
| 25 | "configurationFingerprint" | 27 | "configurationFingerprint" |
| 26 | ]; | 28 | ]; |
| 27 | -var UPDATE_KEYS = ["schemaVersion", "favorites", "apiKeyUpdate", "forceFullSync"]; | 29 | +var UPDATE_KEYS = ["schemaVersion", "favorites", "apiKeyUpdate", "forceFullSync", "languagePreference"]; |
| 28 | var MAX_CLOSE_RESPONSE_LENGTH = 32768; | 30 | var MAX_CLOSE_RESPONSE_LENGTH = 32768; |
| 29 | var MAX_SAFE_INTEGER = 9007199254740991; | 31 | var MAX_SAFE_INTEGER = 9007199254740991; |
| 30 | var FAVORITE_STRING_KEYS = [ | 32 | var FAVORITE_STRING_KEYS = [ |
| @@ -47,19 +49,21 @@ var ROUTING_STRING_KEYS = [ | |||
| 47 | 49 | ||
| 48 | function emptyConfiguration() { | 50 | function emptyConfiguration() { |
| 49 | return { | 51 | return { |
| 50 | - schemaVersion: contracts.CONFIGURATION_VERSION, | 52 | + schemaVersion: CONFIG_SCHEMA_VERSION, |
| 51 | favorites: [], | 53 | favorites: [], |
| 52 | primApiKey: null, | 54 | primApiKey: null, |
| 53 | - keyStatus: contracts.KEY_STATUS.MISSING | 55 | + keyStatus: contracts.KEY_STATUS.MISSING, |
| 56 | + languagePreference: "auto" | ||
| 54 | }; | 57 | }; |
| 55 | } | 58 | } |
| 56 | 59 | ||
| 57 | function copyConfiguration(value) { | 60 | function copyConfiguration(value) { |
| 58 | return { | 61 | return { |
| 59 | - schemaVersion: contracts.CONFIGURATION_VERSION, | 62 | + schemaVersion: CONFIG_SCHEMA_VERSION, |
| 60 | favorites: value.favorites.map(contracts.copyPhoneFavorite), | 63 | favorites: value.favorites.map(contracts.copyPhoneFavorite), |
| 61 | primApiKey: value.primApiKey, | 64 | primApiKey: value.primApiKey, |
| 62 | - keyStatus: value.keyStatus | 65 | + keyStatus: value.keyStatus, |
| 66 | + languagePreference: value.languagePreference | ||
| 63 | }; | 67 | }; |
| 64 | } | 68 | } |
| 65 | 69 | ||
| @@ -162,10 +166,20 @@ function activeWatchLanguage(Pebble) { | |||
| 162 | } | 166 | } |
| 163 | } | 167 | } |
| 164 | 168 | ||
| 169 | +function isLanguagePreference(value) { | ||
| 170 | + return value === "auto" || value === contracts.WIRE_LANGUAGE.EN || value === contracts.WIRE_LANGUAGE.FR; | ||
| 171 | +} | ||
| 172 | + | ||
| 173 | +function effectiveWatchLanguage(Pebble, preference) { | ||
| 174 | + return preference === "auto" ? activeWatchLanguage(Pebble) : preference; | ||
| 175 | +} | ||
| 176 | + | ||
| 165 | function isStoredConfiguration(value) { | 177 | function isStoredConfiguration(value) { |
| 166 | if (!contracts.isObject(value) | 178 | if (!contracts.isObject(value) |
| 167 | || !contracts.hasOnlyKeys(value, CONFIG_RECORD_KEYS) | 179 | || !contracts.hasOnlyKeys(value, CONFIG_RECORD_KEYS) |
| 168 | - || value.schemaVersion !== contracts.CONFIGURATION_VERSION | 180 | + || value.schemaVersion !== CONFIG_SCHEMA_VERSION |
| 181 | + || !Object.prototype.hasOwnProperty.call(value, "languagePreference") | ||
| 182 | + || !isLanguagePreference(value.languagePreference) | ||
| 169 | || !contracts.isPhoneFavoriteList(value.favorites)) return false; | 183 | || !contracts.isPhoneFavoriteList(value.favorites)) return false; |
| 170 | if (value.primApiKey === null) { | 184 | if (value.primApiKey === null) { |
| 171 | return value.keyStatus === contracts.KEY_STATUS.MISSING; | 185 | return value.keyStatus === contracts.KEY_STATUS.MISSING; |
| @@ -179,8 +193,13 @@ function isStoredConfiguration(value) { | |||
| 179 | function recoverStoredConfiguration(value) { | 193 | function recoverStoredConfiguration(value) { |
| 180 | if (!contracts.isObject(value) || !Array.isArray(value.favorites) | 194 | if (!contracts.isObject(value) || !Array.isArray(value.favorites) |
| 181 | || value.schemaVersion !== contracts.SCHEMA_VERSION | 195 | || value.schemaVersion !== contracts.SCHEMA_VERSION |
| 182 | - && value.schemaVersion !== contracts.CONFIGURATION_VERSION) return null; | 196 | + && value.schemaVersion !== contracts.CONFIGURATION_VERSION |
| 197 | + && value.schemaVersion !== CONFIG_SCHEMA_VERSION) return null; | ||
| 183 | var legacy = value.schemaVersion === contracts.SCHEMA_VERSION; | 198 | var legacy = value.schemaVersion === contracts.SCHEMA_VERSION; |
| 199 | + if (value.schemaVersion !== CONFIG_SCHEMA_VERSION | ||
| 200 | + && !Object.prototype.hasOwnProperty.call(value, "languagePreference")) { | ||
| 201 | + value.languagePreference = "auto"; | ||
| 202 | + } | ||
| 184 | // This freshly parsed record is private to restoration. Routing is replaceable | 203 | // This freshly parsed record is private to restoration. Routing is replaceable |
| 185 | // catalog enrichment; preserve every other field for ordinary validation. | 204 | // catalog enrichment; preserve every other field for ordinary validation. |
| 186 | value.favorites.forEach(function (favorite) { | 205 | value.favorites.forEach(function (favorite) { |
| @@ -192,7 +211,7 @@ function recoverStoredConfiguration(value) { | |||
| 192 | && Object.prototype.hasOwnProperty.call(favorite, "routing") | 211 | && Object.prototype.hasOwnProperty.call(favorite, "routing") |
| 193 | && !contracts.isServiceRouting(favorite.routing)) delete favorite.routing; | 212 | && !contracts.isServiceRouting(favorite.routing)) delete favorite.routing; |
| 194 | }); | 213 | }); |
| 195 | - value.schemaVersion = contracts.CONFIGURATION_VERSION; | 214 | + value.schemaVersion = CONFIG_SCHEMA_VERSION; |
| 196 | return isStoredConfiguration(value) ? value : null; | 215 | return isStoredConfiguration(value) ? value : null; |
| 197 | } | 216 | } |
| 198 | 217 | ||
| @@ -202,6 +221,8 @@ function isConfigurationUpdate(value) { | |||
| 202 | && value.schemaVersion === contracts.CONFIGURATION_VERSION | 221 | && value.schemaVersion === contracts.CONFIGURATION_VERSION |
| 203 | && contracts.isPhoneFavoriteList(value.favorites) | 222 | && contracts.isPhoneFavoriteList(value.favorites) |
| 204 | && contracts.isApiKeyUpdate(value.apiKeyUpdate) | 223 | && contracts.isApiKeyUpdate(value.apiKeyUpdate) |
| 224 | + && (!Object.prototype.hasOwnProperty.call(value, "languagePreference") | ||
| 225 | + || isLanguagePreference(value.languagePreference)) | ||
| 205 | && (!Object.prototype.hasOwnProperty.call(value, "forceFullSync") | 226 | && (!Object.prototype.hasOwnProperty.call(value, "forceFullSync") |
| 206 | || typeof value.forceFullSync === "boolean"); | 227 | || typeof value.forceFullSync === "boolean"); |
| 207 | } | 228 | } |
| @@ -460,7 +481,7 @@ function loadConfiguration(storage) { | |||
| 460 | var current = parseStored(storage, CONFIG_STORAGE_KEY); | 481 | var current = parseStored(storage, CONFIG_STORAGE_KEY); |
| 461 | var invalidMarker = parseStored(storage, INVALID_KEY_STATUS_STORAGE_KEY).value; | 482 | var invalidMarker = parseStored(storage, INVALID_KEY_STATUS_STORAGE_KEY).value; |
| 462 | var loaded; | 483 | var loaded; |
| 463 | - var migration = current.value && current.value.schemaVersion === contracts.SCHEMA_VERSION; | 484 | + var migration = current.value && current.value.schemaVersion !== CONFIG_SCHEMA_VERSION; |
| 464 | if (!current.present) return emptyConfiguration(); | 485 | if (!current.present) return emptyConfiguration(); |
| 465 | loaded = recoverStoredConfiguration(current.value); | 486 | loaded = recoverStoredConfiguration(current.value); |
| 466 | if (loaded === null) return null; | 487 | if (loaded === null) return null; |
| @@ -695,16 +716,19 @@ function applyConfigurationUpdate(current, update) { | |||
| 695 | keyStatus = contracts.KEY_STATUS.MISSING; | 716 | keyStatus = contracts.KEY_STATUS.MISSING; |
| 696 | } | 717 | } |
| 697 | return { | 718 | return { |
| 698 | - schemaVersion: contracts.CONFIGURATION_VERSION, | 719 | + schemaVersion: CONFIG_SCHEMA_VERSION, |
| 699 | favorites: update.favorites.map(contracts.copyPhoneFavorite), | 720 | favorites: update.favorites.map(contracts.copyPhoneFavorite), |
| 700 | primApiKey: primApiKey, | 721 | primApiKey: primApiKey, |
| 701 | - keyStatus: keyStatus | 722 | + keyStatus: keyStatus, |
| 723 | + languagePreference: Object.prototype.hasOwnProperty.call(update, "languagePreference") | ||
| 724 | + ? update.languagePreference : current.languagePreference | ||
| 702 | }; | 725 | }; |
| 703 | } | 726 | } |
| 704 | 727 | ||
| 705 | function parseCloseFragment(response) { | 728 | function parseCloseFragment(response) { |
| 706 | var fragment; | 729 | var fragment; |
| 707 | var parsed; | 730 | var parsed; |
| 731 | + var update; | ||
| 708 | var closePrefix = "pebblejs://close#"; | 732 | var closePrefix = "pebblejs://close#"; |
| 709 | if (typeof response !== "string" | 733 | if (typeof response !== "string" |
| 710 | || response.length === 0 | 734 | || response.length === 0 |
| @@ -725,7 +749,7 @@ function parseCloseFragment(response) { | |||
| 725 | } | 749 | } |
| 726 | } | 750 | } |
| 727 | if (!isConfigurationUpdate(parsed)) return null; | 751 | if (!isConfigurationUpdate(parsed)) return null; |
| 728 | - return { | 752 | + update = { |
| 729 | schemaVersion: contracts.CONFIGURATION_VERSION, | 753 | schemaVersion: contracts.CONFIGURATION_VERSION, |
| 730 | favorites: parsed.favorites.map(contracts.copyPhoneFavorite), | 754 | favorites: parsed.favorites.map(contracts.copyPhoneFavorite), |
| 731 | apiKeyUpdate: parsed.apiKeyUpdate.action === "REPLACE" | 755 | apiKeyUpdate: parsed.apiKeyUpdate.action === "REPLACE" |
| @@ -733,6 +757,10 @@ function parseCloseFragment(response) { | |||
| 733 | : { schemaVersion: contracts.SCHEMA_VERSION, action: parsed.apiKeyUpdate.action }, | 757 | : { schemaVersion: contracts.SCHEMA_VERSION, action: parsed.apiKeyUpdate.action }, |
| 734 | forceFullSync: parsed.forceFullSync === true | 758 | forceFullSync: parsed.forceFullSync === true |
| 735 | }; | 759 | }; |
| 760 | + if (Object.prototype.hasOwnProperty.call(parsed, "languagePreference")) { | ||
| 761 | + update.languagePreference = parsed.languagePreference; | ||
| 762 | + } | ||
| 763 | + return update; | ||
| 736 | } | 764 | } |
| 737 | 765 | ||
| 738 | function configurationPageState(value, language) { | 766 | function configurationPageState(value, language) { |
| @@ -740,6 +768,7 @@ function configurationPageState(value, language) { | |||
| 740 | schemaVersion: contracts.CONFIGURATION_VERSION, | 768 | schemaVersion: contracts.CONFIGURATION_VERSION, |
| 741 | hasKey: value.primApiKey !== null, | 769 | hasKey: value.primApiKey !== null, |
| 742 | favorites: value.favorites.map(contracts.copyPhoneFavorite), | 770 | favorites: value.favorites.map(contracts.copyPhoneFavorite), |
| 771 | + languagePreference: value.languagePreference, | ||
| 743 | language: typeof language === "string" && language.length > 0 ? language : "en" | 772 | language: typeof language === "string" && language.length > 0 ? language : "en" |
| 744 | }; | 773 | }; |
| 745 | } | 774 | } |
| @@ -764,6 +793,7 @@ function configurationUrl(baseUrl, value, language) { | |||
| 764 | 793 | ||
| 765 | module.exports = { | 794 | module.exports = { |
| 766 | CONFIG_STORAGE_KEY: CONFIG_STORAGE_KEY, | 795 | CONFIG_STORAGE_KEY: CONFIG_STORAGE_KEY, |
| 796 | + CONFIG_SCHEMA_VERSION: CONFIG_SCHEMA_VERSION, | ||
| 767 | RESULTS_STORAGE_KEY: RESULTS_STORAGE_KEY, | 797 | RESULTS_STORAGE_KEY: RESULTS_STORAGE_KEY, |
| 768 | INVALID_KEY_STATUS_STORAGE_KEY: INVALID_KEY_STATUS_STORAGE_KEY, | 798 | INVALID_KEY_STATUS_STORAGE_KEY: INVALID_KEY_STATUS_STORAGE_KEY, |
| 769 | MAX_CLOSE_RESPONSE_LENGTH: MAX_CLOSE_RESPONSE_LENGTH, | 799 | MAX_CLOSE_RESPONSE_LENGTH: MAX_CLOSE_RESPONSE_LENGTH, |
| @@ -776,6 +806,7 @@ module.exports = { | |||
| 776 | areFavoritesSecretFree: areFavoritesSecretFree, | 806 | areFavoritesSecretFree: areFavoritesSecretFree, |
| 777 | normalizeLanguage: normalizeLanguage, | 807 | normalizeLanguage: normalizeLanguage, |
| 778 | activeWatchLanguage: activeWatchLanguage, | 808 | activeWatchLanguage: activeWatchLanguage, |
| 809 | + effectiveWatchLanguage: effectiveWatchLanguage, | ||
| 779 | loadConfiguration: loadConfiguration, | 810 | loadConfiguration: loadConfiguration, |
| 780 | saveConfiguration: saveConfiguration, | 811 | saveConfiguration: saveConfiguration, |
| 781 | saveInvalidConfiguration: saveInvalidConfiguration, | 812 | saveInvalidConfiguration: saveInvalidConfiguration, |
modified
packages/companion/src/contracts.js +2 -2 | @@ -1,8 +1,8 @@ | ||
| 1 | 1 | "use strict"; |
| 2 | 2 | |
| 3 | 3 | var SCHEMA_VERSION = 1; |
| 4 | -// CONFIGURATION_VERSION 2 covers only the stored configuration record and the | |
| 5 | -// page open/close envelopes. Domain objects, nested favorites and ApiKeyUpdate | |
| 4 | +// CONFIGURATION_VERSION 2 covers the page open/close envelopes. The companion | |
| 5 | +// versions its phone-local stored record separately. Domain objects and ApiKeyUpdate | |
| 6 | 6 | // keep SCHEMA_VERSION 1. Versioning the envelope also protects an empty |
| 7 | 7 | // backup: a phone that cannot read v2 must never blank the configuration with |
| 8 | 8 | // a v1 close. |
| @@ -1,8 +1,8 @@ | |||
| 1 | "use strict"; | 1 | "use strict"; |
| 2 | 2 | ||
| 3 | var SCHEMA_VERSION = 1; | 3 | var SCHEMA_VERSION = 1; |
| 4 | -// CONFIGURATION_VERSION 2 covers only the stored configuration record and the | 4 | +// CONFIGURATION_VERSION 2 covers the page open/close envelopes. The companion |
| 5 | -// page open/close envelopes. Domain objects, nested favorites and ApiKeyUpdate | 5 | +// versions its phone-local stored record separately. Domain objects and ApiKeyUpdate |
| 6 | // keep SCHEMA_VERSION 1. Versioning the envelope also protects an empty | 6 | // keep SCHEMA_VERSION 1. Versioning the envelope also protects an empty |
| 7 | // backup: a phone that cannot read v2 must never blank the configuration with | 7 | // backup: a phone that cannot read v2 must never blank the configuration with |
| 8 | // a v1 close. | 8 | // a v1 close. |
modified
packages/companion/src/index.js +21 -14 | @@ -306,7 +306,7 @@ Companion.prototype._onShowConfiguration = function () { | ||
| 306 | 306 | var language; |
| 307 | 307 | var url; |
| 308 | 308 | if (!this._configurationValid) return; |
| 309 | - language = configuration.activeWatchLanguage(this._Pebble); | |
| 309 | + language = configuration.effectiveWatchLanguage(this._Pebble, this._configuration.languagePreference); | |
| 310 | 310 | url = configuration.configurationUrl(this._configurationUrl, this._configuration, language); |
| 311 | 311 | if (url !== null && typeof this._Pebble.openURL === "function") this._Pebble.openURL(url); |
| 312 | 312 | }; |
| @@ -328,12 +328,15 @@ Companion.prototype._onWebviewClosed = function (event) { | ||
| 328 | 328 | if (this._credentialBlocked |
| 329 | 329 | || this._configuration.keyStatus === contracts.KEY_STATUS.INVALID) { |
| 330 | 330 | pending = { |
| 331 | - schemaVersion: contracts.CONFIGURATION_VERSION, | |
| 331 | + schemaVersion: configuration.CONFIG_SCHEMA_VERSION, | |
| 332 | 332 | favorites: next.favorites.map(contracts.copyPhoneFavorite), |
| 333 | 333 | primApiKey: next.primApiKey, |
| 334 | - keyStatus: contracts.KEY_STATUS.INVALID | |
| 334 | + keyStatus: contracts.KEY_STATUS.INVALID, | |
| 335 | + languagePreference: next.languagePreference | |
| 335 | 336 | }; |
| 336 | - if (!configuration.saveInvalidConfiguration(this._storage, pending)) return; | |
| 337 | + // A journal alone cannot persist edited favorites or language. Commit the | |
| 338 | + // INVALID candidate before removing the previous credential's authority. | |
| 339 | + if (!configuration.saveConfiguration(this._storage, pending)) return; | |
| 337 | 340 | if (!configuration.clearInvalidKeyStatus(this._storage) |
| 338 | 341 | || !configuration.saveConfiguration(this._storage, next)) next = pending; |
| 339 | 342 | } else if (!configuration.clearInvalidKeyStatus(this._storage) |
| @@ -349,7 +352,7 @@ Companion.prototype._onWebviewClosed = function (event) { | ||
| 349 | 352 | nextWatch = next.favorites.map(contracts.copyFavorite); |
| 350 | 353 | wireChanged = JSON.stringify(nextWatch) !== JSON.stringify(this._watchFavorites) |
| 351 | 354 | || next.keyStatus !== this._configuration.keyStatus |
| 352 | - || configuration.activeWatchLanguage(this._Pebble) !== this._watchLanguage; | |
| 355 | + || configuration.effectiveWatchLanguage(this._Pebble, next.languagePreference) !== this._watchLanguage; | |
| 353 | 356 | invalidate = update.apiKeyUpdate.action !== "KEEP" |
| 354 | 357 | || !sameFavoriteContentSet(this._watchFavorites, nextWatch) |
| 355 | 358 | || this._configuration.favorites.some(function (favorite) { |
| @@ -381,7 +384,7 @@ Companion.prototype._sendConfiguration = function (forceFull) { | ||
| 381 | 384 | var binding = this._sync.binding(); |
| 382 | 385 | if (!this._configurationValid || !binding.epoch |
| 383 | 386 | || !configuration.areFavoritesSecretFree(this._watchFavorites, this._configuration.primApiKey, null)) return; |
| 384 | - var language = configuration.activeWatchLanguage(this._Pebble); | |
| 387 | + var language = configuration.effectiveWatchLanguage(this._Pebble, this._configuration.languagePreference); | |
| 385 | 388 | var records; |
| 386 | 389 | try { |
| 387 | 390 | records = this._configuration.favorites.map(function (favorite) { |
| @@ -782,10 +785,11 @@ Companion.prototype._hydrateRouting = function (flight, favorites, complete) { | ||
| 782 | 785 | if (remaining !== 0) return; |
| 783 | 786 | if (Object.keys(recovered).length === 0) { complete(recovered); return; } |
| 784 | 787 | hydrated = { |
| 785 | - schemaVersion: contracts.CONFIGURATION_VERSION, | |
| 788 | + schemaVersion: configuration.CONFIG_SCHEMA_VERSION, | |
| 786 | 789 | favorites: self._configuration.favorites.map(contracts.copyPhoneFavorite), |
| 787 | 790 | primApiKey: self._configuration.primApiKey, |
| 788 | - keyStatus: self._configuration.keyStatus | |
| 791 | + keyStatus: self._configuration.keyStatus, | |
| 792 | + languagePreference: self._configuration.languagePreference | |
| 789 | 793 | }; |
| 790 | 794 | hydrated.favorites.forEach(function (favorite) { |
| 791 | 795 | if (!favorite.routing && recovered[favorite.serviceId] && favorites.some(function (captured) { |
| @@ -800,7 +804,7 @@ Companion.prototype._hydrateRouting = function (flight, favorites, complete) { | ||
| 800 | 804 | return; |
| 801 | 805 | } |
| 802 | 806 | if (changed && configuration.configurationUrl( |
| 803 | - self._configurationUrl, hydrated, configuration.activeWatchLanguage(self._Pebble) | |
| 807 | + self._configurationUrl, hydrated, configuration.effectiveWatchLanguage(self._Pebble, hydrated.languagePreference) | |
| 804 | 808 | ) !== null && configuration.saveConfiguration(self._storage, hydrated) |
| 805 | 809 | && flight.generation === self._lifecycleGeneration) { |
| 806 | 810 | self._configuration = hydrated; |
| @@ -1028,9 +1032,10 @@ Companion.prototype._startOverviewFlight = function (request) { | ||
| 1028 | 1032 | if (result !== null) { |
| 1029 | 1033 | groups[serviceId] = result.group; |
| 1030 | 1034 | var hydrated = { |
| 1031 | - schemaVersion: contracts.CONFIGURATION_VERSION, | |
| 1035 | + schemaVersion: configuration.CONFIG_SCHEMA_VERSION, | |
| 1032 | 1036 | favorites: self._configuration.favorites.map(contracts.copyPhoneFavorite), |
| 1033 | - primApiKey: self._configuration.primApiKey, keyStatus: self._configuration.keyStatus | |
| 1037 | + primApiKey: self._configuration.primApiKey, keyStatus: self._configuration.keyStatus, | |
| 1038 | + languagePreference: self._configuration.languagePreference | |
| 1034 | 1039 | }; |
| 1035 | 1040 | var changed = false; |
| 1036 | 1041 | hydrated.favorites.forEach(function (favorite) { |
| @@ -1043,7 +1048,8 @@ Companion.prototype._startOverviewFlight = function (request) { | ||
| 1043 | 1048 | changed = true; |
| 1044 | 1049 | }); |
| 1045 | 1050 | if (changed && configuration.areFavoritesSecretFree(hydrated.favorites, hydrated.primApiKey, null) |
| 1046 | - && configuration.configurationUrl(self._configurationUrl, hydrated, configuration.activeWatchLanguage(self._Pebble)) !== null | |
| 1051 | + && configuration.configurationUrl(self._configurationUrl, hydrated, | |
| 1052 | + configuration.effectiveWatchLanguage(self._Pebble, hydrated.languagePreference)) !== null | |
| 1047 | 1053 | && configuration.saveConfiguration(self._storage, hydrated) && current()) { |
| 1048 | 1054 | self._configuration = hydrated; |
| 1049 | 1055 | self._sync.enrichPhoneFavorites(hydrated.favorites, flight.generation); |
| @@ -1148,10 +1154,11 @@ Companion.prototype._invalidateCredential = function () { | ||
| 1148 | 1154 | if (this._configuration.primApiKey === null |
| 1149 | 1155 | || this._configuration.keyStatus === contracts.KEY_STATUS.INVALID) return; |
| 1150 | 1156 | invalid = { |
| 1151 | - schemaVersion: contracts.CONFIGURATION_VERSION, | |
| 1157 | + schemaVersion: configuration.CONFIG_SCHEMA_VERSION, | |
| 1152 | 1158 | favorites: this._configuration.favorites.map(contracts.copyPhoneFavorite), |
| 1153 | 1159 | primApiKey: this._configuration.primApiKey, |
| 1154 | - keyStatus: contracts.KEY_STATUS.INVALID | |
| 1160 | + keyStatus: contracts.KEY_STATUS.INVALID, | |
| 1161 | + languagePreference: this._configuration.languagePreference | |
| 1155 | 1162 | }; |
| 1156 | 1163 | configuration.saveInvalidConfiguration(this._storage, invalid); |
| 1157 | 1164 | this._invalidateLifecycle(); |
| @@ -306,7 +306,7 @@ Companion.prototype._onShowConfiguration = function () { | |||
| 306 | var language; | 306 | var language; |
| 307 | var url; | 307 | var url; |
| 308 | if (!this._configurationValid) return; | 308 | if (!this._configurationValid) return; |
| 309 | - language = configuration.activeWatchLanguage(this._Pebble); | 309 | + language = configuration.effectiveWatchLanguage(this._Pebble, this._configuration.languagePreference); |
| 310 | url = configuration.configurationUrl(this._configurationUrl, this._configuration, language); | 310 | url = configuration.configurationUrl(this._configurationUrl, this._configuration, language); |
| 311 | if (url !== null && typeof this._Pebble.openURL === "function") this._Pebble.openURL(url); | 311 | if (url !== null && typeof this._Pebble.openURL === "function") this._Pebble.openURL(url); |
| 312 | }; | 312 | }; |
| @@ -328,12 +328,15 @@ Companion.prototype._onWebviewClosed = function (event) { | |||
| 328 | if (this._credentialBlocked | 328 | if (this._credentialBlocked |
| 329 | || this._configuration.keyStatus === contracts.KEY_STATUS.INVALID) { | 329 | || this._configuration.keyStatus === contracts.KEY_STATUS.INVALID) { |
| 330 | pending = { | 330 | pending = { |
| 331 | - schemaVersion: contracts.CONFIGURATION_VERSION, | 331 | + schemaVersion: configuration.CONFIG_SCHEMA_VERSION, |
| 332 | favorites: next.favorites.map(contracts.copyPhoneFavorite), | 332 | favorites: next.favorites.map(contracts.copyPhoneFavorite), |
| 333 | primApiKey: next.primApiKey, | 333 | primApiKey: next.primApiKey, |
| 334 | - keyStatus: contracts.KEY_STATUS.INVALID | 334 | + keyStatus: contracts.KEY_STATUS.INVALID, |
| 335 | + languagePreference: next.languagePreference | ||
| 335 | }; | 336 | }; |
| 336 | - if (!configuration.saveInvalidConfiguration(this._storage, pending)) return; | 337 | + // A journal alone cannot persist edited favorites or language. Commit the |
| 338 | + // INVALID candidate before removing the previous credential's authority. | ||
| 339 | + if (!configuration.saveConfiguration(this._storage, pending)) return; | ||
| 337 | if (!configuration.clearInvalidKeyStatus(this._storage) | 340 | if (!configuration.clearInvalidKeyStatus(this._storage) |
| 338 | || !configuration.saveConfiguration(this._storage, next)) next = pending; | 341 | || !configuration.saveConfiguration(this._storage, next)) next = pending; |
| 339 | } else if (!configuration.clearInvalidKeyStatus(this._storage) | 342 | } else if (!configuration.clearInvalidKeyStatus(this._storage) |
| @@ -349,7 +352,7 @@ Companion.prototype._onWebviewClosed = function (event) { | |||
| 349 | nextWatch = next.favorites.map(contracts.copyFavorite); | 352 | nextWatch = next.favorites.map(contracts.copyFavorite); |
| 350 | wireChanged = JSON.stringify(nextWatch) !== JSON.stringify(this._watchFavorites) | 353 | wireChanged = JSON.stringify(nextWatch) !== JSON.stringify(this._watchFavorites) |
| 351 | || next.keyStatus !== this._configuration.keyStatus | 354 | || next.keyStatus !== this._configuration.keyStatus |
| 352 | - || configuration.activeWatchLanguage(this._Pebble) !== this._watchLanguage; | 355 | + || configuration.effectiveWatchLanguage(this._Pebble, next.languagePreference) !== this._watchLanguage; |
| 353 | invalidate = update.apiKeyUpdate.action !== "KEEP" | 356 | invalidate = update.apiKeyUpdate.action !== "KEEP" |
| 354 | || !sameFavoriteContentSet(this._watchFavorites, nextWatch) | 357 | || !sameFavoriteContentSet(this._watchFavorites, nextWatch) |
| 355 | || this._configuration.favorites.some(function (favorite) { | 358 | || this._configuration.favorites.some(function (favorite) { |
| @@ -381,7 +384,7 @@ Companion.prototype._sendConfiguration = function (forceFull) { | |||
| 381 | var binding = this._sync.binding(); | 384 | var binding = this._sync.binding(); |
| 382 | if (!this._configurationValid || !binding.epoch | 385 | if (!this._configurationValid || !binding.epoch |
| 383 | || !configuration.areFavoritesSecretFree(this._watchFavorites, this._configuration.primApiKey, null)) return; | 386 | || !configuration.areFavoritesSecretFree(this._watchFavorites, this._configuration.primApiKey, null)) return; |
| 384 | - var language = configuration.activeWatchLanguage(this._Pebble); | 387 | + var language = configuration.effectiveWatchLanguage(this._Pebble, this._configuration.languagePreference); |
| 385 | var records; | 388 | var records; |
| 386 | try { | 389 | try { |
| 387 | records = this._configuration.favorites.map(function (favorite) { | 390 | records = this._configuration.favorites.map(function (favorite) { |
| @@ -782,10 +785,11 @@ Companion.prototype._hydrateRouting = function (flight, favorites, complete) { | |||
| 782 | if (remaining !== 0) return; | 785 | if (remaining !== 0) return; |
| 783 | if (Object.keys(recovered).length === 0) { complete(recovered); return; } | 786 | if (Object.keys(recovered).length === 0) { complete(recovered); return; } |
| 784 | hydrated = { | 787 | hydrated = { |
| 785 | - schemaVersion: contracts.CONFIGURATION_VERSION, | 788 | + schemaVersion: configuration.CONFIG_SCHEMA_VERSION, |
| 786 | favorites: self._configuration.favorites.map(contracts.copyPhoneFavorite), | 789 | favorites: self._configuration.favorites.map(contracts.copyPhoneFavorite), |
| 787 | primApiKey: self._configuration.primApiKey, | 790 | primApiKey: self._configuration.primApiKey, |
| 788 | - keyStatus: self._configuration.keyStatus | 791 | + keyStatus: self._configuration.keyStatus, |
| 792 | + languagePreference: self._configuration.languagePreference | ||
| 789 | }; | 793 | }; |
| 790 | hydrated.favorites.forEach(function (favorite) { | 794 | hydrated.favorites.forEach(function (favorite) { |
| 791 | if (!favorite.routing && recovered[favorite.serviceId] && favorites.some(function (captured) { | 795 | if (!favorite.routing && recovered[favorite.serviceId] && favorites.some(function (captured) { |
| @@ -800,7 +804,7 @@ Companion.prototype._hydrateRouting = function (flight, favorites, complete) { | |||
| 800 | return; | 804 | return; |
| 801 | } | 805 | } |
| 802 | if (changed && configuration.configurationUrl( | 806 | if (changed && configuration.configurationUrl( |
| 803 | - self._configurationUrl, hydrated, configuration.activeWatchLanguage(self._Pebble) | 807 | + self._configurationUrl, hydrated, configuration.effectiveWatchLanguage(self._Pebble, hydrated.languagePreference) |
| 804 | ) !== null && configuration.saveConfiguration(self._storage, hydrated) | 808 | ) !== null && configuration.saveConfiguration(self._storage, hydrated) |
| 805 | && flight.generation === self._lifecycleGeneration) { | 809 | && flight.generation === self._lifecycleGeneration) { |
| 806 | self._configuration = hydrated; | 810 | self._configuration = hydrated; |
| @@ -1028,9 +1032,10 @@ Companion.prototype._startOverviewFlight = function (request) { | |||
| 1028 | if (result !== null) { | 1032 | if (result !== null) { |
| 1029 | groups[serviceId] = result.group; | 1033 | groups[serviceId] = result.group; |
| 1030 | var hydrated = { | 1034 | var hydrated = { |
| 1031 | - schemaVersion: contracts.CONFIGURATION_VERSION, | 1035 | + schemaVersion: configuration.CONFIG_SCHEMA_VERSION, |
| 1032 | favorites: self._configuration.favorites.map(contracts.copyPhoneFavorite), | 1036 | favorites: self._configuration.favorites.map(contracts.copyPhoneFavorite), |
| 1033 | - primApiKey: self._configuration.primApiKey, keyStatus: self._configuration.keyStatus | 1037 | + primApiKey: self._configuration.primApiKey, keyStatus: self._configuration.keyStatus, |
| 1038 | + languagePreference: self._configuration.languagePreference | ||
| 1034 | }; | 1039 | }; |
| 1035 | var changed = false; | 1040 | var changed = false; |
| 1036 | hydrated.favorites.forEach(function (favorite) { | 1041 | hydrated.favorites.forEach(function (favorite) { |
| @@ -1043,7 +1048,8 @@ Companion.prototype._startOverviewFlight = function (request) { | |||
| 1043 | changed = true; | 1048 | changed = true; |
| 1044 | }); | 1049 | }); |
| 1045 | if (changed && configuration.areFavoritesSecretFree(hydrated.favorites, hydrated.primApiKey, null) | 1050 | if (changed && configuration.areFavoritesSecretFree(hydrated.favorites, hydrated.primApiKey, null) |
| 1046 | - && configuration.configurationUrl(self._configurationUrl, hydrated, configuration.activeWatchLanguage(self._Pebble)) !== null | 1051 | + && configuration.configurationUrl(self._configurationUrl, hydrated, |
| 1052 | + configuration.effectiveWatchLanguage(self._Pebble, hydrated.languagePreference)) !== null | ||
| 1047 | && configuration.saveConfiguration(self._storage, hydrated) && current()) { | 1053 | && configuration.saveConfiguration(self._storage, hydrated) && current()) { |
| 1048 | self._configuration = hydrated; | 1054 | self._configuration = hydrated; |
| 1049 | self._sync.enrichPhoneFavorites(hydrated.favorites, flight.generation); | 1055 | self._sync.enrichPhoneFavorites(hydrated.favorites, flight.generation); |
| @@ -1148,10 +1154,11 @@ Companion.prototype._invalidateCredential = function () { | |||
| 1148 | if (this._configuration.primApiKey === null | 1154 | if (this._configuration.primApiKey === null |
| 1149 | || this._configuration.keyStatus === contracts.KEY_STATUS.INVALID) return; | 1155 | || this._configuration.keyStatus === contracts.KEY_STATUS.INVALID) return; |
| 1150 | invalid = { | 1156 | invalid = { |
| 1151 | - schemaVersion: contracts.CONFIGURATION_VERSION, | 1157 | + schemaVersion: configuration.CONFIG_SCHEMA_VERSION, |
| 1152 | favorites: this._configuration.favorites.map(contracts.copyPhoneFavorite), | 1158 | favorites: this._configuration.favorites.map(contracts.copyPhoneFavorite), |
| 1153 | primApiKey: this._configuration.primApiKey, | 1159 | primApiKey: this._configuration.primApiKey, |
| 1154 | - keyStatus: contracts.KEY_STATUS.INVALID | 1160 | + keyStatus: contracts.KEY_STATUS.INVALID, |
| 1161 | + languagePreference: this._configuration.languagePreference | ||
| 1155 | }; | 1162 | }; |
| 1156 | configuration.saveInvalidConfiguration(this._storage, invalid); | 1163 | configuration.saveInvalidConfiguration(this._storage, invalid); |
| 1157 | this._invalidateLifecycle(); | 1164 | this._invalidateLifecycle(); |
modified
packages/companion/test/companion.test.js +218 -15 | @@ -30,11 +30,12 @@ function withoutRouting(favorite) { | ||
| 30 | 30 | return copy; |
| 31 | 31 | } |
| 32 | 32 | |
| 33 | -function configuredStorage(favorites, status) { | |
| 33 | +function configuredStorage(favorites, status, languagePreference) { | |
| 34 | 34 | var storage = new fakes.FakeStorage(); |
| 35 | 35 | assert.equal(configuration.saveConfiguration(storage, { |
| 36 | - schemaVersion: 2, favorites: favorites || [FIRST], primApiKey: TEST_KEY, | |
| 37 | - keyStatus: typeof status === "number" ? status : contracts.KEY_STATUS.CONFIGURED | |
| 36 | + schemaVersion: configuration.CONFIG_SCHEMA_VERSION, favorites: favorites || [FIRST], primApiKey: TEST_KEY, | |
| 37 | + keyStatus: typeof status === "number" ? status : contracts.KEY_STATUS.CONFIGURED, | |
| 38 | + languagePreference: typeof languagePreference === "undefined" ? "auto" : languagePreference | |
| 38 | 39 | }), true); |
| 39 | 40 | storage.writes.length = 0; |
| 40 | 41 | return storage; |
| @@ -208,13 +209,19 @@ function resolveProduction(target, favorites) { | ||
| 208 | 209 | } else respond(target, xhr, 200, trafficBody(favorites)); |
| 209 | 210 | }); |
| 210 | 211 | } |
| 211 | -function closeWith(target, action, favorites, value, forceFull) { | |
| 212 | +function closeWith(target, action, favorites, value, forceFull, languagePreference) { | |
| 212 | 213 | var update = { schemaVersion: 2, favorites: favorites, apiKeyUpdate: { schemaVersion: 1, action: action } }; |
| 213 | 214 | if (action === "REPLACE") update.apiKeyUpdate.value = value; |
| 214 | 215 | if (forceFull) update.forceFullSync = true; |
| 216 | + if (typeof languagePreference !== "undefined") update.languagePreference = languagePreference; | |
| 215 | 217 | target.Pebble.emit("webviewclosed", { response: "pebblejs://close#" + encodeURIComponent(JSON.stringify(update)) }); |
| 216 | 218 | } |
| 217 | 219 | |
| 220 | +function openConfiguration(target) { | |
| 221 | + target.Pebble.emit("showConfiguration"); | |
| 222 | + return JSON.parse(decodeURIComponent(target.Pebble.openedUrls.at(-1).split("#")[1])); | |
| 223 | +} | |
| 224 | + | |
| 218 | 225 | test("Core Android decoded close responses preserve favorites with line colors", function () { |
| 219 | 226 | var target = harness({ storage: configuredStorage([]) }); |
| 220 | 227 | var update = { |
| @@ -229,6 +236,108 @@ test("Core Android decoded close responses preserve favorites with line colors", | ||
| 229 | 236 | target.companion.stop(); |
| 230 | 237 | }); |
| 231 | 238 | |
| 239 | +test("an explicit choice matching the system persists without a sync and returning to Automatic follows fresh locales", function () { | |
| 240 | + var storage = configuredStorage(), target = harness({ storage: storage }), systemLanguage = "fr_FR"; | |
| 241 | + target.Pebble.getActiveWatchInfo = function () { return { language: systemLanguage }; }; | |
| 242 | + ready(target); | |
| 243 | + assert.equal(target.binding.LANGUAGE, "fr"); | |
| 244 | + assert.equal(openConfiguration(target).languagePreference, "auto"); | |
| 245 | + var before = messages(target, T.CONFIG_BEGIN).length; | |
| 246 | + closeWith(target, "KEEP", [FIRST], undefined, false, "fr"); | |
| 247 | + assert.equal(messages(target, T.CONFIG_BEGIN).length, before); | |
| 248 | + assert.equal(configuration.loadConfiguration(storage).languagePreference, "fr"); | |
| 249 | + assert.equal(openConfiguration(target).languagePreference, "fr"); | |
| 250 | + assert.equal(target.xhr.instances.length, 0); | |
| 251 | + target.companion.stop(); | |
| 252 | + | |
| 253 | + systemLanguage = "en_US"; | |
| 254 | + target = harness({ storage: storage }); | |
| 255 | + target.Pebble.getActiveWatchInfo = function () { return { language: systemLanguage }; }; | |
| 256 | + ready(target); | |
| 257 | + assert.equal(target.binding.LANGUAGE, "fr"); | |
| 258 | + var opening = openConfiguration(target); | |
| 259 | + assert.equal(opening.language, "fr"); | |
| 260 | + assert.equal(opening.languagePreference, "fr"); | |
| 261 | + assert.deepEqual(opening.favorites, [FIRST]); | |
| 262 | + assert.equal(target.Pebble.openedUrls.at(-1).includes(TEST_KEY), false); | |
| 263 | + before = messages(target, T.CONFIG_BEGIN).length; | |
| 264 | + closeWith(target, "KEEP", [FIRST]); | |
| 265 | + assert.equal(messages(target, T.CONFIG_BEGIN).length, before); | |
| 266 | + assert.equal(configuration.loadConfiguration(storage).languagePreference, "fr"); | |
| 267 | + | |
| 268 | + closeWith(target, "KEEP", [FIRST], undefined, false, "auto"); | |
| 269 | + acknowledgeConfiguration(target); | |
| 270 | + assert.equal(target.binding.CONFIG_MODE, contracts.CONFIG_MODE.DIFF); | |
| 271 | + assert.equal(target.binding.LANGUAGE, "en"); | |
| 272 | + assert.equal(configuration.loadConfiguration(storage).languagePreference, "auto"); | |
| 273 | + systemLanguage = "FR-ca"; | |
| 274 | + opening = openConfiguration(target); | |
| 275 | + assert.equal(opening.language, "fr"); | |
| 276 | + assert.equal(opening.languagePreference, "auto"); | |
| 277 | + closeWith(target, "KEEP", [FIRST]); | |
| 278 | + acknowledgeConfiguration(target); | |
| 279 | + assert.equal(target.binding.LANGUAGE, "fr"); | |
| 280 | + assert.equal(target.xhr.instances.length, 0); | |
| 281 | + target.companion.stop(); | |
| 282 | +}); | |
| 283 | + | |
| 284 | +test("language-only saves send rehashed appearance records through DIFF without changing favorites or fetching", function () { | |
| 285 | + var favorite = Object.assign({}, FIRST, { displayName: "Mon trajet", destinationLabel: "Gare d’arrivée" }); | |
| 286 | + var target = harness({ storage: configuredStorage([favorite]) }); | |
| 287 | + target.Pebble.getActiveWatchInfo = function () { return { language: "en_US" }; }; | |
| 288 | + ready(target); | |
| 289 | + var hash = messages(target, T.CONFIG_ENTRY).at(-1).DISPLAY_HASH; | |
| 290 | + var appearance = messages(target, T.FAVORITE).at(-1).DISPLAY_RECORD; | |
| 291 | + closeWith(target, "KEEP", [favorite], undefined, false, "fr"); | |
| 292 | + var begin = messages(target, T.CONFIG_BEGIN).at(-1); | |
| 293 | + assert.equal(begin.LANGUAGE, "fr"); | |
| 294 | + assert.equal(begin.CONFIG_MODE, contracts.CONFIG_MODE.DIFF); | |
| 295 | + assert.notEqual(messages(target, T.CONFIG_ENTRY).at(-1).DISPLAY_HASH, hash); | |
| 296 | + acknowledgeConfiguration(target); | |
| 297 | + assert.notEqual(messages(target, T.FAVORITE).at(-1).DISPLAY_RECORD, appearance); | |
| 298 | + assert.equal(messages(target, T.CONFIG_COMMIT).at(-1).REQUEST_ID, begin.REQUEST_ID); | |
| 299 | + var saved = configuration.loadConfiguration(target.storage); | |
| 300 | + assert.deepEqual(saved.favorites, [favorite]); | |
| 301 | + assert.equal(saved.primApiKey, TEST_KEY); | |
| 302 | + assert.equal(saved.languagePreference, "fr"); | |
| 303 | + assert.equal(openConfiguration(target).language, "fr"); | |
| 304 | + assert.equal(target.xhr.instances.length, 0); | |
| 305 | + target.companion.stop(); | |
| 306 | +}); | |
| 307 | + | |
| 308 | +test("cancelled, invalid and unpersisted language saves leave the prior configuration active", function () { | |
| 309 | + var target = harness({ storage: configuredStorage([FIRST], undefined, "fr") }); | |
| 310 | + ready(target); | |
| 311 | + var original = target.storage.getItem(configuration.CONFIG_STORAGE_KEY); | |
| 312 | + var before = messages(target, T.CONFIG_BEGIN).length; | |
| 313 | + ["CANCELLED", "", "pebblejs://close#CANCELLED", "%invalid"].forEach(function (response) { | |
| 314 | + target.Pebble.emit("webviewclosed", { response: response }); | |
| 315 | + }); | |
| 316 | + target.Pebble.emit("webviewclosed", { response: JSON.stringify({ | |
| 317 | + schemaVersion: 2, favorites: [], apiKeyUpdate: { schemaVersion: 1, action: "REMOVE" }, languagePreference: null | |
| 318 | + }) }); | |
| 319 | + assert.equal(target.storage.getItem(configuration.CONFIG_STORAGE_KEY), original); | |
| 320 | + assert.equal(messages(target, T.CONFIG_BEGIN).length, before); | |
| 321 | + | |
| 322 | + var write = target.storage.setItem, fail = true; | |
| 323 | + target.storage.setItem = function (key, value) { | |
| 324 | + if (key === configuration.CONFIG_STORAGE_KEY && fail) { | |
| 325 | + fail = false; | |
| 326 | + write.call(this, key, "partial write"); | |
| 327 | + } else write.call(this, key, value); | |
| 328 | + }; | |
| 329 | + closeWith(target, "KEEP", [FIRST], undefined, false, "en"); | |
| 330 | + assert.equal(target.storage.getItem(configuration.CONFIG_STORAGE_KEY), original); | |
| 331 | + assert.equal(messages(target, T.CONFIG_BEGIN).length, before); | |
| 332 | + var opening = openConfiguration(target); | |
| 333 | + assert.equal(opening.languagePreference, "fr"); | |
| 334 | + assert.equal(opening.language, "fr"); | |
| 335 | + assert.deepEqual(opening.favorites, [FIRST]); | |
| 336 | + assert.equal(configuration.loadConfiguration(target.storage).primApiKey, TEST_KEY); | |
| 337 | + assert.equal(target.xhr.instances.length, 0); | |
| 338 | + target.companion.stop(); | |
| 339 | +}); | |
| 340 | + | |
| 232 | 341 | function serviceRow(favorite) { |
| 233 | 342 | return { serviceId: favorite.serviceId, stopLabel: favorite.stopLabel, lineLabel: favorite.lineLabel, |
| 234 | 343 | destinationLabel: favorite.destinationLabel, lineMode: favorite.lineMode, |
| @@ -438,7 +547,8 @@ test("the failure table deterministically projects unconfigured, stale, or unava | ||
| 438 | 547 | if (failure.keyStatus === contracts.KEY_STATUS.MISSING) { |
| 439 | 548 | storage = new fakes.FakeStorage(); |
| 440 | 549 | assert.equal(configuration.saveConfiguration(storage, { |
| 441 | - schemaVersion: 2, favorites: [FIRST], primApiKey: null, keyStatus: contracts.KEY_STATUS.MISSING | |
| 550 | + schemaVersion: configuration.CONFIG_SCHEMA_VERSION, favorites: [FIRST], primApiKey: null, | |
| 551 | + keyStatus: contracts.KEY_STATUS.MISSING, languagePreference: "auto" | |
| 442 | 552 | }), true); |
| 443 | 553 | } else storage = configuredStorage([FIRST], failure.keyStatus); |
| 444 | 554 | if (!failure.noCache) seedOverview(storage, [FIRST], clock); |
| @@ -571,7 +681,7 @@ test("superseded settled selections get terminal data without launching obsolete | ||
| 571 | 681 | target.companion.stop(); |
| 572 | 682 | }); |
| 573 | 683 | |
| 574 | -test("traffic completion remains token-bound and its line cache is language-specific", function () { | |
| 684 | +test("saved preferences and Automatic use separate traffic caches without refetching a fresh language", function () { | |
| 575 | 685 | var favorites = [FIRST, phoneFavorite(2, 1)], storage = configuredStorage(favorites), clock = new fakes.FakeClock(); |
| 576 | 686 | seedOverview(storage, favorites, clock); |
| 577 | 687 | var language = "en", target = harness({ storage: storage, clock: clock }); |
| @@ -583,12 +693,64 @@ test("traffic completion remains token-bound and its line cache is language-spec | ||
| 583 | 693 | sendTraffic(target, "same-line", favorites[1]); |
| 584 | 694 | assert.equal(requests(target, "traffic").length, 1); |
| 585 | 695 | assert.equal(finalTransfer(target, "same-line").favoriteId, favorites[1].id); |
| 586 | - language = "fr"; closeWith(target, "KEEP", favorites); acknowledgeConfiguration(target); | |
| 696 | + closeWith(target, "KEEP", favorites, undefined, false, "fr"); acknowledgeConfiguration(target); | |
| 697 | + assert.equal(target.binding.LANGUAGE, "fr"); | |
| 698 | + assert.equal(requests(target, "traffic").length, 1); | |
| 587 | 699 | sendTraffic(target, "translated", FIRST); |
| 588 | 700 | assert.equal(requests(target, "traffic").length, 2); |
| 589 | 701 | assert.equal(requests(target, "traffic")[1].headers["Accept-Language"], undefined); |
| 590 | 702 | resolveProduction(target, favorites); |
| 591 | 703 | assert.notDeepEqual(finalTransfer(target, "translated").records, finalTransfer(target, "old-traffic").records); |
| 704 | + language = "fr"; closeWith(target, "KEEP", favorites, undefined, false, "auto"); | |
| 705 | + language = "en"; closeWith(target, "KEEP", favorites); acknowledgeConfiguration(target); | |
| 706 | + sendTraffic(target, "back-to-english", FIRST); | |
| 707 | + assert.equal(requests(target, "traffic").length, 2); | |
| 708 | + assert.deepEqual(finalTransfer(target, "back-to-english").records, finalTransfer(target, "old-traffic").records); | |
| 709 | + target.companion.stop(); | |
| 710 | +}); | |
| 711 | + | |
| 712 | +test("an English override keeps in-flight French fallback and both language caches bound through restart", function () { | |
| 713 | + var favorite = phoneFavorite(1, 200), storage = configuredStorage([favorite]); | |
| 714 | + var clock = new fakes.FakeClock(Date.parse("2026-01-15T09:00:00Z")); | |
| 715 | + var target = harness({ storage: storage, clock: clock }); | |
| 716 | + target.Pebble.getActiveWatchInfo = function () { return { language: "fr_FR" }; }; | |
| 717 | + ready(target); | |
| 718 | + sendTraffic(target, "french", favorite); | |
| 719 | + assert.equal(requests(target, "traffic")[0].headers["Accept-Language"], undefined); | |
| 720 | + closeWith(target, "KEEP", [favorite], undefined, false, "en"); | |
| 721 | + assert.equal(requests(target, "traffic").length, 1); | |
| 722 | + acknowledgeConfiguration(target); | |
| 723 | + assert.equal(target.binding.LANGUAGE, "en"); | |
| 724 | + sendTraffic(target, "english", favorite); | |
| 725 | + assert.equal(requests(target, "traffic")[1].headers["Accept-Language"], "en"); | |
| 726 | + var french = require("../../../fixtures/traffic/global.json"); | |
| 727 | + var english = JSON.parse(JSON.stringify(french)); | |
| 728 | + english.disruptions[0].title = "English title"; | |
| 729 | + delete english.disruptions[0].message; | |
| 730 | + respond(target, requests(target, "traffic")[1], 200, english); | |
| 731 | + assert.equal(requests(target, "traffic").length, 2); | |
| 732 | + assert.equal(transfers(target, "english").length, 0); | |
| 733 | + respond(target, requests(target, "traffic")[0], 200, french); | |
| 734 | + var translated = finalTransfer(target, "english"); | |
| 735 | + assert.notEqual(finalTransfer(target, "french").generation, translated.generation); | |
| 736 | + assert.equal(translated.generation, target.binding.DISPLAY_GENERATION); | |
| 737 | + var cache = configuration.loadCache(storage, [favorite]); | |
| 738 | + var en = configuration.findTrafficDetail(cache, favorite.serviceId, "en", "cached-en", favorite.id); | |
| 739 | + var fr = configuration.findTrafficDetail(cache, favorite.serviceId, "fr", "cached-fr", favorite.id); | |
| 740 | + assert.equal(en.result.title, "Métro 2 : ralentissements"); | |
| 741 | + assert.equal(en.result.text, "Le trafic est ralenti & les temps d’attente sont allongés."); | |
| 742 | + assert.equal(fr.result.title, en.result.title); | |
| 743 | + assert.equal(fr.result.text, en.result.text); | |
| 744 | + target.companion.stop(); | |
| 745 | + | |
| 746 | + target = harness({ storage: storage, clock: clock }); | |
| 747 | + target.Pebble.getActiveWatchInfo = function () { return { language: "fr_FR" }; }; | |
| 748 | + ready(target); | |
| 749 | + assert.equal(target.binding.LANGUAGE, "en"); | |
| 750 | + sendTraffic(target, "restored-english", favorite); | |
| 751 | + assert.deepEqual(finalTransfer(target, "restored-english").records, translated.records); | |
| 752 | + assert.equal(target.xhr.instances.length, 0); | |
| 753 | + assert.equal(openConfiguration(target).language, "en"); | |
| 592 | 754 | target.companion.stop(); |
| 593 | 755 | }); |
| 594 | 756 | |
| @@ -629,7 +791,7 @@ test("explicit FULL is one-shot, sends complete inventory, and is not persisted" | ||
| 629 | 791 | |
| 630 | 792 | test("unresolved routing uses a pinned public revision once per service without metadata resynchronization", function () { |
| 631 | 793 | var unresolved = withoutRouting(FIRST), duplicate = Object.assign({}, unresolved, { id: "unresolved-copy", sortOrder: 1 }); |
| 632 | - var storage = configuredStorage([unresolved, duplicate]), target = harness({ storage: storage }); | |
| 794 | + var storage = configuredStorage([unresolved, duplicate], undefined, "fr"), target = harness({ storage: storage }); | |
| 633 | 795 | ready(target); var before = messages(target, T.CONFIG_BEGIN).length; |
| 634 | 796 | sendOverview(target, "hydrate"); |
| 635 | 797 | assert.equal(requests(target, "static").length, 1); assert.equal(requests(target, "departures").length, 0); |
| @@ -637,6 +799,7 @@ test("unresolved routing uses a pinned public revision once per service without | ||
| 637 | 799 | requests(target, "static").forEach(function (xhr) { assert.equal(xhr.headers.apikey, undefined); assert.equal(xhr.headers.Authorization, undefined); }); |
| 638 | 800 | assert.equal(requests(target, "departures").length, 1); |
| 639 | 801 | assert.deepEqual(configuration.loadConfiguration(storage).favorites[1].routing, FIRST.routing); |
| 802 | + assert.equal(openConfiguration(target).languagePreference, "fr"); | |
| 640 | 803 | assert.equal(storage.writes.filter(function (write) { return write.key === configuration.CONFIG_STORAGE_KEY; }).length, 1); |
| 641 | 804 | assert.equal(messages(target, T.CONFIG_BEGIN).length, before); |
| 642 | 805 | resolveProduction(target, [FIRST]); |
| @@ -753,11 +916,12 @@ test("catalog revision mismatch cannot hydrate favorites or send credentials to | ||
| 753 | 916 | }); |
| 754 | 917 | |
| 755 | 918 | test("authentication rejection invalidates credentials durably, cancels network, and completes accepted tokens", function () { |
| 756 | - var favorites = [FIRST, SECOND], target = harness({ storage: configuredStorage(favorites) }); | |
| 919 | + var favorites = [FIRST, SECOND], target = harness({ storage: configuredStorage(favorites, undefined, "fr") }); | |
| 757 | 920 | ready(target); sendOverview(target, "old-screen"); sendTraffic(target, "current-screen", SECOND); |
| 758 | 921 | respond(target, requests(target, "departures")[0], 401, ""); |
| 759 | 922 | assert.equal(configuration.loadConfiguration(target.storage).keyStatus, contracts.KEY_STATUS.INVALID); |
| 760 | 923 | assert.deepEqual(configuration.loadConfiguration(target.storage).favorites[0].routing, FIRST.routing); |
| 924 | + assert.equal(configuration.loadConfiguration(target.storage).languagePreference, "fr"); | |
| 761 | 925 | assert.equal(requests(target, "departures")[1].aborted, true); assert.equal(requests(target, "traffic")[0].aborted, true); |
| 762 | 926 | acknowledgeConfiguration(target, 0); |
| 763 | 927 | assert.equal(rows(target, "old-screen")[0].error, 3); |
| @@ -766,6 +930,7 @@ test("authentication rejection invalidates credentials durably, cancels network, | ||
| 766 | 930 | sendOverview(target, "blocked"); target.clock.advance(3600000); |
| 767 | 931 | assert.equal(target.xhr.instances.length, before); assert.equal(rows(target, "blocked")[0].error, 3); |
| 768 | 932 | target.companion.stop(); target = harness({ storage: target.storage }); ready(target, 0); |
| 933 | + assert.equal(target.binding.LANGUAGE, "fr"); | |
| 769 | 934 | sendOverview(target, "blocked-after-restart"); assert.equal(target.xhr.instances.length, 0); |
| 770 | 935 | assert.equal(rows(target, "blocked-after-restart")[0].error, 3); target.companion.stop(); |
| 771 | 936 | }); |
| @@ -780,6 +945,42 @@ test("the invalid-key journal blocks restart even when the primary configuration | ||
| 780 | 945 | assert.deepEqual(finalTransfer(target, "restart").records, ["e03"]); target.companion.stop(); |
| 781 | 946 | }); |
| 782 | 947 | |
| 948 | +test("marker-only invalidation cannot apply an unpersisted replacement's favorites or language", function () { | |
| 949 | + var storage = configuredStorage([FIRST], undefined, "fr"), write = storage.setItem; | |
| 950 | + var original = storage.getItem(configuration.CONFIG_STORAGE_KEY), target = harness({ storage: storage }); | |
| 951 | + ready(target); | |
| 952 | + storage.setItem = function (key, value) { | |
| 953 | + if (key === configuration.CONFIG_STORAGE_KEY) throw new Error("primary storage unavailable"); | |
| 954 | + write.call(this, key, value); | |
| 955 | + }; | |
| 956 | + sendTraffic(target, "invalid-before-save"); | |
| 957 | + respond(target, requests(target, "traffic")[0], 403, ""); | |
| 958 | + acknowledgeConfiguration(target, 0); | |
| 959 | + assert.equal(storage.getItem(configuration.CONFIG_STORAGE_KEY), original); | |
| 960 | + assert.equal(configuration.loadConfiguration(storage).keyStatus, contracts.KEY_STATUS.INVALID); | |
| 961 | + var marker = storage.getItem(configuration.INVALID_KEY_STATUS_STORAGE_KEY); | |
| 962 | + var before = messages(target, T.CONFIG_BEGIN).length; | |
| 963 | + closeWith(target, "REPLACE", [Object.assign({}, SECOND, { sortOrder: 0 })], TEST_KEY, false, "en"); | |
| 964 | + assert.equal(storage.getItem(configuration.CONFIG_STORAGE_KEY), original); | |
| 965 | + assert.equal(storage.getItem(configuration.INVALID_KEY_STATUS_STORAGE_KEY), marker); | |
| 966 | + assert.equal(messages(target, T.CONFIG_BEGIN).length, before); | |
| 967 | + var opening = openConfiguration(target); | |
| 968 | + assert.equal(opening.languagePreference, "fr"); | |
| 969 | + assert.equal(opening.language, "fr"); | |
| 970 | + assert.deepEqual(opening.favorites, [FIRST]); | |
| 971 | + target.companion.stop(); | |
| 972 | + | |
| 973 | + storage.setItem = write; | |
| 974 | + target = harness({ storage: storage }); ready(target, 0); | |
| 975 | + assert.equal(target.binding.LANGUAGE, "fr"); | |
| 976 | + assert.equal(configuration.loadConfiguration(storage).keyStatus, contracts.KEY_STATUS.INVALID); | |
| 977 | + assert.deepEqual(openConfiguration(target).favorites, [FIRST]); | |
| 978 | + sendTraffic(target, "still-blocked"); | |
| 979 | + assert.deepEqual(finalTransfer(target, "still-blocked").records, ["e03"]); | |
| 980 | + assert.equal(target.xhr.instances.length, 0); | |
| 981 | + target.companion.stop(); | |
| 982 | +}); | |
| 983 | + | |
| 783 | 984 | test("credential replacement ignores saved callbacks from the canceled lifecycle", function () { |
| 784 | 985 | var target = harness(); ready(target); sendTraffic(target, "old-key"); |
| 785 | 986 | var xhr = requests(target, "traffic")[0], late = xhr.onload; |
| @@ -810,18 +1011,19 @@ test("credential removal erases the key, retains favorites, and blocks PRIM unti | ||
| 810 | 1011 | |
| 811 | 1012 | test("interrupted same-key replacement never clears durable invalid-key authority", function () { |
| 812 | 1013 | ["marker-removal", "configured-write"].forEach(function (failure) { |
| 813 | - var storage = configuredStorage(), target = harness({ storage: storage }), write = storage.setItem, remove = storage.removeItem; | |
| 1014 | + var storage = configuredStorage([FIRST], undefined, "fr"), target = harness({ storage: storage }), write = storage.setItem, remove = storage.removeItem; | |
| 1015 | + storage.removeItem = function (key) { | |
| 1016 | + if (failure === "marker-removal" && key === configuration.INVALID_KEY_STATUS_STORAGE_KEY) throw new Error("retained marker"); | |
| 1017 | + remove.call(this, key); | |
| 1018 | + }; | |
| 814 | 1019 | ready(target); sendTraffic(target, "invalid-before-replacement"); respond(target, requests(target, "traffic")[0], 401, ""); |
| 815 | 1020 | storage.setItem = function (key, value) { |
| 816 | 1021 | if (failure === "configured-write" && key === configuration.CONFIG_STORAGE_KEY && JSON.parse(value).keyStatus === contracts.KEY_STATUS.CONFIGURED) throw new Error("interrupted"); |
| 817 | 1022 | write.call(this, key, value); |
| 818 | 1023 | }; |
| 819 | - storage.removeItem = function (key) { | |
| 820 | - if (failure === "marker-removal" && key === configuration.INVALID_KEY_STATUS_STORAGE_KEY) throw new Error("retained marker"); | |
| 821 | - remove.call(this, key); | |
| 822 | - }; | |
| 823 | 1024 | closeWith(target, "REPLACE", [FIRST], TEST_KEY); target.companion.stop(); |
| 824 | 1025 | assert.equal(configuration.loadConfiguration(storage).keyStatus, contracts.KEY_STATUS.INVALID); |
| 1026 | + assert.equal(configuration.loadConfiguration(storage).languagePreference, "fr"); | |
| 825 | 1027 | }); |
| 826 | 1028 | }); |
| 827 | 1029 | |
| @@ -986,7 +1188,7 @@ function finishCaptured(target, call, favorite, seconds) { | ||
| 986 | 1188 | |
| 987 | 1189 | test("journey flights migrate a single null arrival through real annexes before PRIM and preserve labels", function () { |
| 988 | 1190 | var resolved = journeyFavorite(), favorite = Object.assign({}, resolved, { arrivalPlaceId: null, destinationLabel: "Historic label" }); |
| 989 | - var target = harness({ storage: configuredStorage([favorite]), realCatalog: true }); | |
| 1191 | + var target = harness({ storage: configuredStorage([favorite], undefined, "fr"), realCatalog: true }); | |
| 990 | 1192 | ready(target); |
| 991 | 1193 | var calls = captureDepartures(target); |
| 992 | 1194 | sendOverview(target, "cache-only-null", contracts.REQUEST_TRIGGER.CACHE_ONLY); |
| @@ -1001,6 +1203,7 @@ test("journey flights migrate a single null arrival through real annexes before | ||
| 1001 | 1203 | var saved = configuration.loadConfiguration(target.storage).favorites[0]; |
| 1002 | 1204 | assert.equal(saved.arrivalPlaceId, resolved.arrivalPlaceId); |
| 1003 | 1205 | assert.equal(saved.destinationLabel, "Historic label"); |
| 1206 | + assert.equal(openConfiguration(target).languagePreference, "fr"); | |
| 1004 | 1207 | finishCaptured(target, calls[0], resolved, 180); |
| 1005 | 1208 | respond(target, requests(target, "traffic")[0], 200, trafficBody([resolved])); |
| 1006 | 1209 | assert.equal(rows(target, "migrated")[0].departures[0].expectedAt, Math.floor(target.clock.now() / 1000) + 180); |
| @@ -30,11 +30,12 @@ function withoutRouting(favorite) { | |||
| 30 | return copy; | 30 | return copy; |
| 31 | } | 31 | } |
| 32 | 32 | ||
| 33 | -function configuredStorage(favorites, status) { | 33 | +function configuredStorage(favorites, status, languagePreference) { |
| 34 | var storage = new fakes.FakeStorage(); | 34 | var storage = new fakes.FakeStorage(); |
| 35 | assert.equal(configuration.saveConfiguration(storage, { | 35 | assert.equal(configuration.saveConfiguration(storage, { |
| 36 | - schemaVersion: 2, favorites: favorites || [FIRST], primApiKey: TEST_KEY, | 36 | + schemaVersion: configuration.CONFIG_SCHEMA_VERSION, favorites: favorites || [FIRST], primApiKey: TEST_KEY, |
| 37 | - keyStatus: typeof status === "number" ? status : contracts.KEY_STATUS.CONFIGURED | 37 | + keyStatus: typeof status === "number" ? status : contracts.KEY_STATUS.CONFIGURED, |
| 38 | + languagePreference: typeof languagePreference === "undefined" ? "auto" : languagePreference | ||
| 38 | }), true); | 39 | }), true); |
| 39 | storage.writes.length = 0; | 40 | storage.writes.length = 0; |
| 40 | return storage; | 41 | return storage; |
| @@ -208,13 +209,19 @@ function resolveProduction(target, favorites) { | |||
| 208 | } else respond(target, xhr, 200, trafficBody(favorites)); | 209 | } else respond(target, xhr, 200, trafficBody(favorites)); |
| 209 | }); | 210 | }); |
| 210 | } | 211 | } |
| 211 | -function closeWith(target, action, favorites, value, forceFull) { | 212 | +function closeWith(target, action, favorites, value, forceFull, languagePreference) { |
| 212 | var update = { schemaVersion: 2, favorites: favorites, apiKeyUpdate: { schemaVersion: 1, action: action } }; | 213 | var update = { schemaVersion: 2, favorites: favorites, apiKeyUpdate: { schemaVersion: 1, action: action } }; |
| 213 | if (action === "REPLACE") update.apiKeyUpdate.value = value; | 214 | if (action === "REPLACE") update.apiKeyUpdate.value = value; |
| 214 | if (forceFull) update.forceFullSync = true; | 215 | if (forceFull) update.forceFullSync = true; |
| 216 | + if (typeof languagePreference !== "undefined") update.languagePreference = languagePreference; | ||
| 215 | target.Pebble.emit("webviewclosed", { response: "pebblejs://close#" + encodeURIComponent(JSON.stringify(update)) }); | 217 | target.Pebble.emit("webviewclosed", { response: "pebblejs://close#" + encodeURIComponent(JSON.stringify(update)) }); |
| 216 | } | 218 | } |
| 217 | 219 | ||
| 220 | +function openConfiguration(target) { | ||
| 221 | + target.Pebble.emit("showConfiguration"); | ||
| 222 | + return JSON.parse(decodeURIComponent(target.Pebble.openedUrls.at(-1).split("#")[1])); | ||
| 223 | +} | ||
| 224 | + | ||
| 218 | test("Core Android decoded close responses preserve favorites with line colors", function () { | 225 | test("Core Android decoded close responses preserve favorites with line colors", function () { |
| 219 | var target = harness({ storage: configuredStorage([]) }); | 226 | var target = harness({ storage: configuredStorage([]) }); |
| 220 | var update = { | 227 | var update = { |
| @@ -229,6 +236,108 @@ test("Core Android decoded close responses preserve favorites with line colors", | |||
| 229 | target.companion.stop(); | 236 | target.companion.stop(); |
| 230 | }); | 237 | }); |
| 231 | 238 | ||
| 239 | +test("an explicit choice matching the system persists without a sync and returning to Automatic follows fresh locales", function () { | ||
| 240 | + var storage = configuredStorage(), target = harness({ storage: storage }), systemLanguage = "fr_FR"; | ||
| 241 | + target.Pebble.getActiveWatchInfo = function () { return { language: systemLanguage }; }; | ||
| 242 | + ready(target); | ||
| 243 | + assert.equal(target.binding.LANGUAGE, "fr"); | ||
| 244 | + assert.equal(openConfiguration(target).languagePreference, "auto"); | ||
| 245 | + var before = messages(target, T.CONFIG_BEGIN).length; | ||
| 246 | + closeWith(target, "KEEP", [FIRST], undefined, false, "fr"); | ||
| 247 | + assert.equal(messages(target, T.CONFIG_BEGIN).length, before); | ||
| 248 | + assert.equal(configuration.loadConfiguration(storage).languagePreference, "fr"); | ||
| 249 | + assert.equal(openConfiguration(target).languagePreference, "fr"); | ||
| 250 | + assert.equal(target.xhr.instances.length, 0); | ||
| 251 | + target.companion.stop(); | ||
| 252 | + | ||
| 253 | + systemLanguage = "en_US"; | ||
| 254 | + target = harness({ storage: storage }); | ||
| 255 | + target.Pebble.getActiveWatchInfo = function () { return { language: systemLanguage }; }; | ||
| 256 | + ready(target); | ||
| 257 | + assert.equal(target.binding.LANGUAGE, "fr"); | ||
| 258 | + var opening = openConfiguration(target); | ||
| 259 | + assert.equal(opening.language, "fr"); | ||
| 260 | + assert.equal(opening.languagePreference, "fr"); | ||
| 261 | + assert.deepEqual(opening.favorites, [FIRST]); | ||
| 262 | + assert.equal(target.Pebble.openedUrls.at(-1).includes(TEST_KEY), false); | ||
| 263 | + before = messages(target, T.CONFIG_BEGIN).length; | ||
| 264 | + closeWith(target, "KEEP", [FIRST]); | ||
| 265 | + assert.equal(messages(target, T.CONFIG_BEGIN).length, before); | ||
| 266 | + assert.equal(configuration.loadConfiguration(storage).languagePreference, "fr"); | ||
| 267 | + | ||
| 268 | + closeWith(target, "KEEP", [FIRST], undefined, false, "auto"); | ||
| 269 | + acknowledgeConfiguration(target); | ||
| 270 | + assert.equal(target.binding.CONFIG_MODE, contracts.CONFIG_MODE.DIFF); | ||
| 271 | + assert.equal(target.binding.LANGUAGE, "en"); | ||
| 272 | + assert.equal(configuration.loadConfiguration(storage).languagePreference, "auto"); | ||
| 273 | + systemLanguage = "FR-ca"; | ||
| 274 | + opening = openConfiguration(target); | ||
| 275 | + assert.equal(opening.language, "fr"); | ||
| 276 | + assert.equal(opening.languagePreference, "auto"); | ||
| 277 | + closeWith(target, "KEEP", [FIRST]); | ||
| 278 | + acknowledgeConfiguration(target); | ||
| 279 | + assert.equal(target.binding.LANGUAGE, "fr"); | ||
| 280 | + assert.equal(target.xhr.instances.length, 0); | ||
| 281 | + target.companion.stop(); | ||
| 282 | +}); | ||
| 283 | + | ||
| 284 | +test("language-only saves send rehashed appearance records through DIFF without changing favorites or fetching", function () { | ||
| 285 | + var favorite = Object.assign({}, FIRST, { displayName: "Mon trajet", destinationLabel: "Gare d’arrivée" }); | ||
| 286 | + var target = harness({ storage: configuredStorage([favorite]) }); | ||
| 287 | + target.Pebble.getActiveWatchInfo = function () { return { language: "en_US" }; }; | ||
| 288 | + ready(target); | ||
| 289 | + var hash = messages(target, T.CONFIG_ENTRY).at(-1).DISPLAY_HASH; | ||
| 290 | + var appearance = messages(target, T.FAVORITE).at(-1).DISPLAY_RECORD; | ||
| 291 | + closeWith(target, "KEEP", [favorite], undefined, false, "fr"); | ||
| 292 | + var begin = messages(target, T.CONFIG_BEGIN).at(-1); | ||
| 293 | + assert.equal(begin.LANGUAGE, "fr"); | ||
| 294 | + assert.equal(begin.CONFIG_MODE, contracts.CONFIG_MODE.DIFF); | ||
| 295 | + assert.notEqual(messages(target, T.CONFIG_ENTRY).at(-1).DISPLAY_HASH, hash); | ||
| 296 | + acknowledgeConfiguration(target); | ||
| 297 | + assert.notEqual(messages(target, T.FAVORITE).at(-1).DISPLAY_RECORD, appearance); | ||
| 298 | + assert.equal(messages(target, T.CONFIG_COMMIT).at(-1).REQUEST_ID, begin.REQUEST_ID); | ||
| 299 | + var saved = configuration.loadConfiguration(target.storage); | ||
| 300 | + assert.deepEqual(saved.favorites, [favorite]); | ||
| 301 | + assert.equal(saved.primApiKey, TEST_KEY); | ||
| 302 | + assert.equal(saved.languagePreference, "fr"); | ||
| 303 | + assert.equal(openConfiguration(target).language, "fr"); | ||
| 304 | + assert.equal(target.xhr.instances.length, 0); | ||
| 305 | + target.companion.stop(); | ||
| 306 | +}); | ||
| 307 | + | ||
| 308 | +test("cancelled, invalid and unpersisted language saves leave the prior configuration active", function () { | ||
| 309 | + var target = harness({ storage: configuredStorage([FIRST], undefined, "fr") }); | ||
| 310 | + ready(target); | ||
| 311 | + var original = target.storage.getItem(configuration.CONFIG_STORAGE_KEY); | ||
| 312 | + var before = messages(target, T.CONFIG_BEGIN).length; | ||
| 313 | + ["CANCELLED", "", "pebblejs://close#CANCELLED", "%invalid"].forEach(function (response) { | ||
| 314 | + target.Pebble.emit("webviewclosed", { response: response }); | ||
| 315 | + }); | ||
| 316 | + target.Pebble.emit("webviewclosed", { response: JSON.stringify({ | ||
| 317 | + schemaVersion: 2, favorites: [], apiKeyUpdate: { schemaVersion: 1, action: "REMOVE" }, languagePreference: null | ||
| 318 | + }) }); | ||
| 319 | + assert.equal(target.storage.getItem(configuration.CONFIG_STORAGE_KEY), original); | ||
| 320 | + assert.equal(messages(target, T.CONFIG_BEGIN).length, before); | ||
| 321 | + | ||
| 322 | + var write = target.storage.setItem, fail = true; | ||
| 323 | + target.storage.setItem = function (key, value) { | ||
| 324 | + if (key === configuration.CONFIG_STORAGE_KEY && fail) { | ||
| 325 | + fail = false; | ||
| 326 | + write.call(this, key, "partial write"); | ||
| 327 | + } else write.call(this, key, value); | ||
| 328 | + }; | ||
| 329 | + closeWith(target, "KEEP", [FIRST], undefined, false, "en"); | ||
| 330 | + assert.equal(target.storage.getItem(configuration.CONFIG_STORAGE_KEY), original); | ||
| 331 | + assert.equal(messages(target, T.CONFIG_BEGIN).length, before); | ||
| 332 | + var opening = openConfiguration(target); | ||
| 333 | + assert.equal(opening.languagePreference, "fr"); | ||
| 334 | + assert.equal(opening.language, "fr"); | ||
| 335 | + assert.deepEqual(opening.favorites, [FIRST]); | ||
| 336 | + assert.equal(configuration.loadConfiguration(target.storage).primApiKey, TEST_KEY); | ||
| 337 | + assert.equal(target.xhr.instances.length, 0); | ||
| 338 | + target.companion.stop(); | ||
| 339 | +}); | ||
| 340 | + | ||
| 232 | function serviceRow(favorite) { | 341 | function serviceRow(favorite) { |
| 233 | return { serviceId: favorite.serviceId, stopLabel: favorite.stopLabel, lineLabel: favorite.lineLabel, | 342 | return { serviceId: favorite.serviceId, stopLabel: favorite.stopLabel, lineLabel: favorite.lineLabel, |
| 234 | destinationLabel: favorite.destinationLabel, lineMode: favorite.lineMode, | 343 | destinationLabel: favorite.destinationLabel, lineMode: favorite.lineMode, |
| @@ -438,7 +547,8 @@ test("the failure table deterministically projects unconfigured, stale, or unava | |||
| 438 | if (failure.keyStatus === contracts.KEY_STATUS.MISSING) { | 547 | if (failure.keyStatus === contracts.KEY_STATUS.MISSING) { |
| 439 | storage = new fakes.FakeStorage(); | 548 | storage = new fakes.FakeStorage(); |
| 440 | assert.equal(configuration.saveConfiguration(storage, { | 549 | assert.equal(configuration.saveConfiguration(storage, { |
| 441 | - schemaVersion: 2, favorites: [FIRST], primApiKey: null, keyStatus: contracts.KEY_STATUS.MISSING | 550 | + schemaVersion: configuration.CONFIG_SCHEMA_VERSION, favorites: [FIRST], primApiKey: null, |
| 551 | + keyStatus: contracts.KEY_STATUS.MISSING, languagePreference: "auto" | ||
| 442 | }), true); | 552 | }), true); |
| 443 | } else storage = configuredStorage([FIRST], failure.keyStatus); | 553 | } else storage = configuredStorage([FIRST], failure.keyStatus); |
| 444 | if (!failure.noCache) seedOverview(storage, [FIRST], clock); | 554 | if (!failure.noCache) seedOverview(storage, [FIRST], clock); |
| @@ -571,7 +681,7 @@ test("superseded settled selections get terminal data without launching obsolete | |||
| 571 | target.companion.stop(); | 681 | target.companion.stop(); |
| 572 | }); | 682 | }); |
| 573 | 683 | ||
| 574 | -test("traffic completion remains token-bound and its line cache is language-specific", function () { | 684 | +test("saved preferences and Automatic use separate traffic caches without refetching a fresh language", function () { |
| 575 | var favorites = [FIRST, phoneFavorite(2, 1)], storage = configuredStorage(favorites), clock = new fakes.FakeClock(); | 685 | var favorites = [FIRST, phoneFavorite(2, 1)], storage = configuredStorage(favorites), clock = new fakes.FakeClock(); |
| 576 | seedOverview(storage, favorites, clock); | 686 | seedOverview(storage, favorites, clock); |
| 577 | var language = "en", target = harness({ storage: storage, clock: clock }); | 687 | var language = "en", target = harness({ storage: storage, clock: clock }); |
| @@ -583,12 +693,64 @@ test("traffic completion remains token-bound and its line cache is language-spec | |||
| 583 | sendTraffic(target, "same-line", favorites[1]); | 693 | sendTraffic(target, "same-line", favorites[1]); |
| 584 | assert.equal(requests(target, "traffic").length, 1); | 694 | assert.equal(requests(target, "traffic").length, 1); |
| 585 | assert.equal(finalTransfer(target, "same-line").favoriteId, favorites[1].id); | 695 | assert.equal(finalTransfer(target, "same-line").favoriteId, favorites[1].id); |
| 586 | - language = "fr"; closeWith(target, "KEEP", favorites); acknowledgeConfiguration(target); | 696 | + closeWith(target, "KEEP", favorites, undefined, false, "fr"); acknowledgeConfiguration(target); |
| 697 | + assert.equal(target.binding.LANGUAGE, "fr"); | ||
| 698 | + assert.equal(requests(target, "traffic").length, 1); | ||
| 587 | sendTraffic(target, "translated", FIRST); | 699 | sendTraffic(target, "translated", FIRST); |
| 588 | assert.equal(requests(target, "traffic").length, 2); | 700 | assert.equal(requests(target, "traffic").length, 2); |
| 589 | assert.equal(requests(target, "traffic")[1].headers["Accept-Language"], undefined); | 701 | assert.equal(requests(target, "traffic")[1].headers["Accept-Language"], undefined); |
| 590 | resolveProduction(target, favorites); | 702 | resolveProduction(target, favorites); |
| 591 | assert.notDeepEqual(finalTransfer(target, "translated").records, finalTransfer(target, "old-traffic").records); | 703 | assert.notDeepEqual(finalTransfer(target, "translated").records, finalTransfer(target, "old-traffic").records); |
| 704 | + language = "fr"; closeWith(target, "KEEP", favorites, undefined, false, "auto"); | ||
| 705 | + language = "en"; closeWith(target, "KEEP", favorites); acknowledgeConfiguration(target); | ||
| 706 | + sendTraffic(target, "back-to-english", FIRST); | ||
| 707 | + assert.equal(requests(target, "traffic").length, 2); | ||
| 708 | + assert.deepEqual(finalTransfer(target, "back-to-english").records, finalTransfer(target, "old-traffic").records); | ||
| 709 | + target.companion.stop(); | ||
| 710 | +}); | ||
| 711 | + | ||
| 712 | +test("an English override keeps in-flight French fallback and both language caches bound through restart", function () { | ||
| 713 | + var favorite = phoneFavorite(1, 200), storage = configuredStorage([favorite]); | ||
| 714 | + var clock = new fakes.FakeClock(Date.parse("2026-01-15T09:00:00Z")); | ||
| 715 | + var target = harness({ storage: storage, clock: clock }); | ||
| 716 | + target.Pebble.getActiveWatchInfo = function () { return { language: "fr_FR" }; }; | ||
| 717 | + ready(target); | ||
| 718 | + sendTraffic(target, "french", favorite); | ||
| 719 | + assert.equal(requests(target, "traffic")[0].headers["Accept-Language"], undefined); | ||
| 720 | + closeWith(target, "KEEP", [favorite], undefined, false, "en"); | ||
| 721 | + assert.equal(requests(target, "traffic").length, 1); | ||
| 722 | + acknowledgeConfiguration(target); | ||
| 723 | + assert.equal(target.binding.LANGUAGE, "en"); | ||
| 724 | + sendTraffic(target, "english", favorite); | ||
| 725 | + assert.equal(requests(target, "traffic")[1].headers["Accept-Language"], "en"); | ||
| 726 | + var french = require("../../../fixtures/traffic/global.json"); | ||
| 727 | + var english = JSON.parse(JSON.stringify(french)); | ||
| 728 | + english.disruptions[0].title = "English title"; | ||
| 729 | + delete english.disruptions[0].message; | ||
| 730 | + respond(target, requests(target, "traffic")[1], 200, english); | ||
| 731 | + assert.equal(requests(target, "traffic").length, 2); | ||
| 732 | + assert.equal(transfers(target, "english").length, 0); | ||
| 733 | + respond(target, requests(target, "traffic")[0], 200, french); | ||
| 734 | + var translated = finalTransfer(target, "english"); | ||
| 735 | + assert.notEqual(finalTransfer(target, "french").generation, translated.generation); | ||
| 736 | + assert.equal(translated.generation, target.binding.DISPLAY_GENERATION); | ||
| 737 | + var cache = configuration.loadCache(storage, [favorite]); | ||
| 738 | + var en = configuration.findTrafficDetail(cache, favorite.serviceId, "en", "cached-en", favorite.id); | ||
| 739 | + var fr = configuration.findTrafficDetail(cache, favorite.serviceId, "fr", "cached-fr", favorite.id); | ||
| 740 | + assert.equal(en.result.title, "Métro 2 : ralentissements"); | ||
| 741 | + assert.equal(en.result.text, "Le trafic est ralenti & les temps d’attente sont allongés."); | ||
| 742 | + assert.equal(fr.result.title, en.result.title); | ||
| 743 | + assert.equal(fr.result.text, en.result.text); | ||
| 744 | + target.companion.stop(); | ||
| 745 | + | ||
| 746 | + target = harness({ storage: storage, clock: clock }); | ||
| 747 | + target.Pebble.getActiveWatchInfo = function () { return { language: "fr_FR" }; }; | ||
| 748 | + ready(target); | ||
| 749 | + assert.equal(target.binding.LANGUAGE, "en"); | ||
| 750 | + sendTraffic(target, "restored-english", favorite); | ||
| 751 | + assert.deepEqual(finalTransfer(target, "restored-english").records, translated.records); | ||
| 752 | + assert.equal(target.xhr.instances.length, 0); | ||
| 753 | + assert.equal(openConfiguration(target).language, "en"); | ||
| 592 | target.companion.stop(); | 754 | target.companion.stop(); |
| 593 | }); | 755 | }); |
| 594 | 756 | ||
| @@ -629,7 +791,7 @@ test("explicit FULL is one-shot, sends complete inventory, and is not persisted" | |||
| 629 | 791 | ||
| 630 | test("unresolved routing uses a pinned public revision once per service without metadata resynchronization", function () { | 792 | test("unresolved routing uses a pinned public revision once per service without metadata resynchronization", function () { |
| 631 | var unresolved = withoutRouting(FIRST), duplicate = Object.assign({}, unresolved, { id: "unresolved-copy", sortOrder: 1 }); | 793 | var unresolved = withoutRouting(FIRST), duplicate = Object.assign({}, unresolved, { id: "unresolved-copy", sortOrder: 1 }); |
| 632 | - var storage = configuredStorage([unresolved, duplicate]), target = harness({ storage: storage }); | 794 | + var storage = configuredStorage([unresolved, duplicate], undefined, "fr"), target = harness({ storage: storage }); |
| 633 | ready(target); var before = messages(target, T.CONFIG_BEGIN).length; | 795 | ready(target); var before = messages(target, T.CONFIG_BEGIN).length; |
| 634 | sendOverview(target, "hydrate"); | 796 | sendOverview(target, "hydrate"); |
| 635 | assert.equal(requests(target, "static").length, 1); assert.equal(requests(target, "departures").length, 0); | 797 | assert.equal(requests(target, "static").length, 1); assert.equal(requests(target, "departures").length, 0); |
| @@ -637,6 +799,7 @@ test("unresolved routing uses a pinned public revision once per service without | |||
| 637 | requests(target, "static").forEach(function (xhr) { assert.equal(xhr.headers.apikey, undefined); assert.equal(xhr.headers.Authorization, undefined); }); | 799 | requests(target, "static").forEach(function (xhr) { assert.equal(xhr.headers.apikey, undefined); assert.equal(xhr.headers.Authorization, undefined); }); |
| 638 | assert.equal(requests(target, "departures").length, 1); | 800 | assert.equal(requests(target, "departures").length, 1); |
| 639 | assert.deepEqual(configuration.loadConfiguration(storage).favorites[1].routing, FIRST.routing); | 801 | assert.deepEqual(configuration.loadConfiguration(storage).favorites[1].routing, FIRST.routing); |
| 802 | + assert.equal(openConfiguration(target).languagePreference, "fr"); | ||
| 640 | assert.equal(storage.writes.filter(function (write) { return write.key === configuration.CONFIG_STORAGE_KEY; }).length, 1); | 803 | assert.equal(storage.writes.filter(function (write) { return write.key === configuration.CONFIG_STORAGE_KEY; }).length, 1); |
| 641 | assert.equal(messages(target, T.CONFIG_BEGIN).length, before); | 804 | assert.equal(messages(target, T.CONFIG_BEGIN).length, before); |
| 642 | resolveProduction(target, [FIRST]); | 805 | resolveProduction(target, [FIRST]); |
| @@ -753,11 +916,12 @@ test("catalog revision mismatch cannot hydrate favorites or send credentials to | |||
| 753 | }); | 916 | }); |
| 754 | 917 | ||
| 755 | test("authentication rejection invalidates credentials durably, cancels network, and completes accepted tokens", function () { | 918 | test("authentication rejection invalidates credentials durably, cancels network, and completes accepted tokens", function () { |
| 756 | - var favorites = [FIRST, SECOND], target = harness({ storage: configuredStorage(favorites) }); | 919 | + var favorites = [FIRST, SECOND], target = harness({ storage: configuredStorage(favorites, undefined, "fr") }); |
| 757 | ready(target); sendOverview(target, "old-screen"); sendTraffic(target, "current-screen", SECOND); | 920 | ready(target); sendOverview(target, "old-screen"); sendTraffic(target, "current-screen", SECOND); |
| 758 | respond(target, requests(target, "departures")[0], 401, ""); | 921 | respond(target, requests(target, "departures")[0], 401, ""); |
| 759 | assert.equal(configuration.loadConfiguration(target.storage).keyStatus, contracts.KEY_STATUS.INVALID); | 922 | assert.equal(configuration.loadConfiguration(target.storage).keyStatus, contracts.KEY_STATUS.INVALID); |
| 760 | assert.deepEqual(configuration.loadConfiguration(target.storage).favorites[0].routing, FIRST.routing); | 923 | assert.deepEqual(configuration.loadConfiguration(target.storage).favorites[0].routing, FIRST.routing); |
| 924 | + assert.equal(configuration.loadConfiguration(target.storage).languagePreference, "fr"); | ||
| 761 | assert.equal(requests(target, "departures")[1].aborted, true); assert.equal(requests(target, "traffic")[0].aborted, true); | 925 | assert.equal(requests(target, "departures")[1].aborted, true); assert.equal(requests(target, "traffic")[0].aborted, true); |
| 762 | acknowledgeConfiguration(target, 0); | 926 | acknowledgeConfiguration(target, 0); |
| 763 | assert.equal(rows(target, "old-screen")[0].error, 3); | 927 | assert.equal(rows(target, "old-screen")[0].error, 3); |
| @@ -766,6 +930,7 @@ test("authentication rejection invalidates credentials durably, cancels network, | |||
| 766 | sendOverview(target, "blocked"); target.clock.advance(3600000); | 930 | sendOverview(target, "blocked"); target.clock.advance(3600000); |
| 767 | assert.equal(target.xhr.instances.length, before); assert.equal(rows(target, "blocked")[0].error, 3); | 931 | assert.equal(target.xhr.instances.length, before); assert.equal(rows(target, "blocked")[0].error, 3); |
| 768 | target.companion.stop(); target = harness({ storage: target.storage }); ready(target, 0); | 932 | target.companion.stop(); target = harness({ storage: target.storage }); ready(target, 0); |
| 933 | + assert.equal(target.binding.LANGUAGE, "fr"); | ||
| 769 | sendOverview(target, "blocked-after-restart"); assert.equal(target.xhr.instances.length, 0); | 934 | sendOverview(target, "blocked-after-restart"); assert.equal(target.xhr.instances.length, 0); |
| 770 | assert.equal(rows(target, "blocked-after-restart")[0].error, 3); target.companion.stop(); | 935 | assert.equal(rows(target, "blocked-after-restart")[0].error, 3); target.companion.stop(); |
| 771 | }); | 936 | }); |
| @@ -780,6 +945,42 @@ test("the invalid-key journal blocks restart even when the primary configuration | |||
| 780 | assert.deepEqual(finalTransfer(target, "restart").records, ["e03"]); target.companion.stop(); | 945 | assert.deepEqual(finalTransfer(target, "restart").records, ["e03"]); target.companion.stop(); |
| 781 | }); | 946 | }); |
| 782 | 947 | ||
| 948 | +test("marker-only invalidation cannot apply an unpersisted replacement's favorites or language", function () { | ||
| 949 | + var storage = configuredStorage([FIRST], undefined, "fr"), write = storage.setItem; | ||
| 950 | + var original = storage.getItem(configuration.CONFIG_STORAGE_KEY), target = harness({ storage: storage }); | ||
| 951 | + ready(target); | ||
| 952 | + storage.setItem = function (key, value) { | ||
| 953 | + if (key === configuration.CONFIG_STORAGE_KEY) throw new Error("primary storage unavailable"); | ||
| 954 | + write.call(this, key, value); | ||
| 955 | + }; | ||
| 956 | + sendTraffic(target, "invalid-before-save"); | ||
| 957 | + respond(target, requests(target, "traffic")[0], 403, ""); | ||
| 958 | + acknowledgeConfiguration(target, 0); | ||
| 959 | + assert.equal(storage.getItem(configuration.CONFIG_STORAGE_KEY), original); | ||
| 960 | + assert.equal(configuration.loadConfiguration(storage).keyStatus, contracts.KEY_STATUS.INVALID); | ||
| 961 | + var marker = storage.getItem(configuration.INVALID_KEY_STATUS_STORAGE_KEY); | ||
| 962 | + var before = messages(target, T.CONFIG_BEGIN).length; | ||
| 963 | + closeWith(target, "REPLACE", [Object.assign({}, SECOND, { sortOrder: 0 })], TEST_KEY, false, "en"); | ||
| 964 | + assert.equal(storage.getItem(configuration.CONFIG_STORAGE_KEY), original); | ||
| 965 | + assert.equal(storage.getItem(configuration.INVALID_KEY_STATUS_STORAGE_KEY), marker); | ||
| 966 | + assert.equal(messages(target, T.CONFIG_BEGIN).length, before); | ||
| 967 | + var opening = openConfiguration(target); | ||
| 968 | + assert.equal(opening.languagePreference, "fr"); | ||
| 969 | + assert.equal(opening.language, "fr"); | ||
| 970 | + assert.deepEqual(opening.favorites, [FIRST]); | ||
| 971 | + target.companion.stop(); | ||
| 972 | + | ||
| 973 | + storage.setItem = write; | ||
| 974 | + target = harness({ storage: storage }); ready(target, 0); | ||
| 975 | + assert.equal(target.binding.LANGUAGE, "fr"); | ||
| 976 | + assert.equal(configuration.loadConfiguration(storage).keyStatus, contracts.KEY_STATUS.INVALID); | ||
| 977 | + assert.deepEqual(openConfiguration(target).favorites, [FIRST]); | ||
| 978 | + sendTraffic(target, "still-blocked"); | ||
| 979 | + assert.deepEqual(finalTransfer(target, "still-blocked").records, ["e03"]); | ||
| 980 | + assert.equal(target.xhr.instances.length, 0); | ||
| 981 | + target.companion.stop(); | ||
| 982 | +}); | ||
| 983 | + | ||
| 783 | test("credential replacement ignores saved callbacks from the canceled lifecycle", function () { | 984 | test("credential replacement ignores saved callbacks from the canceled lifecycle", function () { |
| 784 | var target = harness(); ready(target); sendTraffic(target, "old-key"); | 985 | var target = harness(); ready(target); sendTraffic(target, "old-key"); |
| 785 | var xhr = requests(target, "traffic")[0], late = xhr.onload; | 986 | var xhr = requests(target, "traffic")[0], late = xhr.onload; |
| @@ -810,18 +1011,19 @@ test("credential removal erases the key, retains favorites, and blocks PRIM unti | |||
| 810 | 1011 | ||
| 811 | test("interrupted same-key replacement never clears durable invalid-key authority", function () { | 1012 | test("interrupted same-key replacement never clears durable invalid-key authority", function () { |
| 812 | ["marker-removal", "configured-write"].forEach(function (failure) { | 1013 | ["marker-removal", "configured-write"].forEach(function (failure) { |
| 813 | - var storage = configuredStorage(), target = harness({ storage: storage }), write = storage.setItem, remove = storage.removeItem; | 1014 | + var storage = configuredStorage([FIRST], undefined, "fr"), target = harness({ storage: storage }), write = storage.setItem, remove = storage.removeItem; |
| 1015 | + storage.removeItem = function (key) { | ||
| 1016 | + if (failure === "marker-removal" && key === configuration.INVALID_KEY_STATUS_STORAGE_KEY) throw new Error("retained marker"); | ||
| 1017 | + remove.call(this, key); | ||
| 1018 | + }; | ||
| 814 | ready(target); sendTraffic(target, "invalid-before-replacement"); respond(target, requests(target, "traffic")[0], 401, ""); | 1019 | ready(target); sendTraffic(target, "invalid-before-replacement"); respond(target, requests(target, "traffic")[0], 401, ""); |
| 815 | storage.setItem = function (key, value) { | 1020 | storage.setItem = function (key, value) { |
| 816 | if (failure === "configured-write" && key === configuration.CONFIG_STORAGE_KEY && JSON.parse(value).keyStatus === contracts.KEY_STATUS.CONFIGURED) throw new Error("interrupted"); | 1021 | if (failure === "configured-write" && key === configuration.CONFIG_STORAGE_KEY && JSON.parse(value).keyStatus === contracts.KEY_STATUS.CONFIGURED) throw new Error("interrupted"); |
| 817 | write.call(this, key, value); | 1022 | write.call(this, key, value); |
| 818 | }; | 1023 | }; |
| 819 | - storage.removeItem = function (key) { | ||
| 820 | - if (failure === "marker-removal" && key === configuration.INVALID_KEY_STATUS_STORAGE_KEY) throw new Error("retained marker"); | ||
| 821 | - remove.call(this, key); | ||
| 822 | - }; | ||
| 823 | closeWith(target, "REPLACE", [FIRST], TEST_KEY); target.companion.stop(); | 1024 | closeWith(target, "REPLACE", [FIRST], TEST_KEY); target.companion.stop(); |
| 824 | assert.equal(configuration.loadConfiguration(storage).keyStatus, contracts.KEY_STATUS.INVALID); | 1025 | assert.equal(configuration.loadConfiguration(storage).keyStatus, contracts.KEY_STATUS.INVALID); |
| 1026 | + assert.equal(configuration.loadConfiguration(storage).languagePreference, "fr"); | ||
| 825 | }); | 1027 | }); |
| 826 | }); | 1028 | }); |
| 827 | 1029 | ||
| @@ -986,7 +1188,7 @@ function finishCaptured(target, call, favorite, seconds) { | |||
| 986 | 1188 | ||
| 987 | test("journey flights migrate a single null arrival through real annexes before PRIM and preserve labels", function () { | 1189 | test("journey flights migrate a single null arrival through real annexes before PRIM and preserve labels", function () { |
| 988 | var resolved = journeyFavorite(), favorite = Object.assign({}, resolved, { arrivalPlaceId: null, destinationLabel: "Historic label" }); | 1190 | var resolved = journeyFavorite(), favorite = Object.assign({}, resolved, { arrivalPlaceId: null, destinationLabel: "Historic label" }); |
| 989 | - var target = harness({ storage: configuredStorage([favorite]), realCatalog: true }); | 1191 | + var target = harness({ storage: configuredStorage([favorite], undefined, "fr"), realCatalog: true }); |
| 990 | ready(target); | 1192 | ready(target); |
| 991 | var calls = captureDepartures(target); | 1193 | var calls = captureDepartures(target); |
| 992 | sendOverview(target, "cache-only-null", contracts.REQUEST_TRIGGER.CACHE_ONLY); | 1194 | sendOverview(target, "cache-only-null", contracts.REQUEST_TRIGGER.CACHE_ONLY); |
| @@ -1001,6 +1203,7 @@ test("journey flights migrate a single null arrival through real annexes before | |||
| 1001 | var saved = configuration.loadConfiguration(target.storage).favorites[0]; | 1203 | var saved = configuration.loadConfiguration(target.storage).favorites[0]; |
| 1002 | assert.equal(saved.arrivalPlaceId, resolved.arrivalPlaceId); | 1204 | assert.equal(saved.arrivalPlaceId, resolved.arrivalPlaceId); |
| 1003 | assert.equal(saved.destinationLabel, "Historic label"); | 1205 | assert.equal(saved.destinationLabel, "Historic label"); |
| 1206 | + assert.equal(openConfiguration(target).languagePreference, "fr"); | ||
| 1004 | finishCaptured(target, calls[0], resolved, 180); | 1207 | finishCaptured(target, calls[0], resolved, 180); |
| 1005 | respond(target, requests(target, "traffic")[0], 200, trafficBody([resolved])); | 1208 | respond(target, requests(target, "traffic")[0], 200, trafficBody([resolved])); |
| 1006 | assert.equal(rows(target, "migrated")[0].departures[0].expectedAt, Math.floor(target.clock.now() / 1000) + 180); | 1209 | assert.equal(rows(target, "migrated")[0].departures[0].expectedAt, Math.floor(target.clock.now() / 1000) + 180); |
modified
packages/companion/test/configuration-journey.test.js +88 -30 | @@ -9,10 +9,12 @@ const fixture = require("../../../fixtures/departures/foundation.json"); | ||
| 9 | 9 | const KEY = "journey-storage-fixture-key"; |
| 10 | 10 | const ARRIVAL = "plc_" + "a".repeat(43); |
| 11 | 11 | const ROUTING = { monitoringRef: "STIF:StopPoint:Q:1:", lineRef: "STIF:Line::1:", destinationRef: "STIF:StopPoint:Q:2:" }; |
| 12 | -function record(version = 2) { | |
| 12 | +function record(version = configuration.CONFIG_SCHEMA_VERSION) { | |
| 13 | 13 | const favorite = { ...fixture.favorite, arrivalPlaceId: ARRIVAL, routing: { ...ROUTING } }; |
| 14 | 14 | if (version === 1) delete favorite.arrivalPlaceId; |
| 15 | - return { schemaVersion: version, favorites: [favorite], primApiKey: KEY, keyStatus: C.KEY_STATUS.CONFIGURED }; | |
| 15 | + const value = { schemaVersion: version, favorites: [favorite], primApiKey: KEY, keyStatus: C.KEY_STATUS.CONFIGURED }; | |
| 16 | + if (version === configuration.CONFIG_SCHEMA_VERSION) value.languagePreference = "auto"; | |
| 17 | + return value; | |
| 16 | 18 | } |
| 17 | 19 | function storageFor(value) { |
| 18 | 20 | const storage = new FakeStorage(); |
| @@ -20,33 +22,37 @@ function storageFor(value) { | ||
| 20 | 22 | return storage; |
| 21 | 23 | } |
| 22 | 24 | |
| 23 | -test("legacy storage migrates once without losing presentation, credentials or routing", () => { | |
| 24 | - const legacy = record(1), storage = storageFor(legacy); | |
| 25 | - const expected = { ...legacy, schemaVersion: 2, favorites: [{ ...legacy.favorites[0], arrivalPlaceId: null }] }; | |
| 26 | - assert.deepEqual(configuration.loadConfiguration(storage), expected); | |
| 27 | - assert.deepEqual(JSON.parse(storage.getItem(configuration.CONFIG_STORAGE_KEY)), expected); | |
| 28 | - const writes = storage.writes.length; | |
| 29 | - assert.deepEqual(configuration.loadConfiguration(storage), expected); | |
| 30 | - assert.equal(storage.writes.length, writes); | |
| 31 | - assert.equal(configuration.isStoredConfiguration(legacy), false); | |
| 25 | +test("both supported phone records migrate once to Automatic without losing favorites or credentials", () => { | |
| 26 | + for (const version of [1, 2]) { | |
| 27 | + const legacy = record(version), storage = storageFor(legacy); | |
| 28 | + const expected = { ...legacy, schemaVersion: configuration.CONFIG_SCHEMA_VERSION, languagePreference: "auto", | |
| 29 | + favorites: [{ ...legacy.favorites[0], arrivalPlaceId: version === 1 ? null : ARRIVAL }] }; | |
| 30 | + assert.deepEqual(configuration.loadConfiguration(storage), expected); | |
| 31 | + assert.deepEqual(JSON.parse(storage.getItem(configuration.CONFIG_STORAGE_KEY)), expected); | |
| 32 | + const writes = storage.writes.length; | |
| 33 | + assert.deepEqual(configuration.loadConfiguration(storage), expected); | |
| 34 | + assert.equal(storage.writes.length, writes); | |
| 35 | + assert.equal(configuration.isStoredConfiguration(legacy), false); | |
| 36 | + } | |
| 32 | 37 | }); |
| 33 | 38 | |
| 34 | 39 | test("storage never treats malformed or missing v2 arrivals as a legacy omission", () => { |
| 35 | - for (const version of [1, 2]) { | |
| 40 | + for (const version of [1, 2, configuration.CONFIG_SCHEMA_VERSION]) { | |
| 36 | 41 | const value = record(version); |
| 37 | 42 | value.favorites[0].arrivalPlaceId = "not-a-place"; |
| 38 | 43 | const storage = storageFor(value), before = storage.getItem(configuration.CONFIG_STORAGE_KEY); |
| 39 | 44 | assert.equal(configuration.loadConfiguration(storage), null); |
| 40 | 45 | assert.equal(storage.getItem(configuration.CONFIG_STORAGE_KEY), before); |
| 41 | 46 | } |
| 42 | - const value = record(); delete value.favorites[0].arrivalPlaceId; | |
| 47 | + const value = record(2); delete value.favorites[0].arrivalPlaceId; | |
| 43 | 48 | assert.equal(configuration.loadConfiguration(storageFor(value)), null); |
| 44 | 49 | const inherited = record(); delete inherited.favorites[0].arrivalPlaceId; |
| 45 | 50 | Object.setPrototypeOf(inherited.favorites[0], { arrivalPlaceId: ARRIVAL }); |
| 46 | 51 | assert.equal(configuration.isStoredConfiguration(inherited), false); |
| 47 | 52 | const explicit = record(1); |
| 48 | 53 | explicit.favorites[0].arrivalPlaceId = ARRIVAL; |
| 49 | - assert.deepEqual(configuration.loadConfiguration(storageFor(explicit)), { ...explicit, schemaVersion: 2 }); | |
| 54 | + assert.deepEqual(configuration.loadConfiguration(storageFor(explicit)), | |
| 55 | + { ...explicit, schemaVersion: configuration.CONFIG_SCHEMA_VERSION, languagePreference: "auto" }); | |
| 50 | 56 | }); |
| 51 | 57 | |
| 52 | 58 | test("routing recovery does not erase an explicit arrival and rejects unrelated corruption", () => { |
| @@ -57,21 +63,25 @@ test("routing recovery does not erase an explicit arrival and rejects unrelated | ||
| 57 | 63 | assert.equal(configuration.loadConfiguration(storageFor(value)), null); |
| 58 | 64 | }); |
| 59 | 65 | |
| 60 | -test("migration readback failure restores the exact old bytes and exposes no writable configuration", () => { | |
| 61 | - const storage = storageFor(record(1)), original = storage.getItem(configuration.CONFIG_STORAGE_KEY); | |
| 62 | - const write = storage.setItem; | |
| 63 | - let fail = true; | |
| 64 | - storage.setItem = function (key, value) { | |
| 65 | - if (key === configuration.CONFIG_STORAGE_KEY && fail) { | |
| 66 | - fail = false; | |
| 67 | - write.call(this, key, "corrupted-write"); | |
| 68 | - } else write.call(this, key, value); | |
| 69 | - }; | |
| 70 | - assert.equal(configuration.loadConfiguration(storage), null); | |
| 71 | - assert.equal(storage.getItem(configuration.CONFIG_STORAGE_KEY), original); | |
| 72 | - assert.equal(configuration.applyConfigurationUpdate(null, { schemaVersion: 2, favorites: [], | |
| 73 | - apiKeyUpdate: { schemaVersion: 1, action: "KEEP" } }), null); | |
| 74 | - assert.equal(configuration.loadConfiguration(storage).favorites[0].arrivalPlaceId, null); | |
| 66 | +test("migration readback failure restores either old format and exposes no writable configuration", () => { | |
| 67 | + for (const version of [1, 2]) { | |
| 68 | + const storage = storageFor(record(version)), original = storage.getItem(configuration.CONFIG_STORAGE_KEY); | |
| 69 | + const write = storage.setItem; | |
| 70 | + let fail = true; | |
| 71 | + storage.setItem = function (key, value) { | |
| 72 | + if (key === configuration.CONFIG_STORAGE_KEY && fail) { | |
| 73 | + fail = false; | |
| 74 | + write.call(this, key, "corrupted-write"); | |
| 75 | + } else write.call(this, key, value); | |
| 76 | + }; | |
| 77 | + assert.equal(configuration.loadConfiguration(storage), null); | |
| 78 | + assert.equal(storage.getItem(configuration.CONFIG_STORAGE_KEY), original); | |
| 79 | + assert.equal(configuration.applyConfigurationUpdate(null, { schemaVersion: 2, favorites: [], | |
| 80 | + apiKeyUpdate: { schemaVersion: 1, action: "KEEP" } }), null); | |
| 81 | + const restored = configuration.loadConfiguration(storage); | |
| 82 | + assert.equal(restored.favorites[0].arrivalPlaceId, version === 1 ? null : ARRIVAL); | |
| 83 | + assert.equal(restored.languagePreference, "auto"); | |
| 84 | + } | |
| 75 | 85 | }); |
| 76 | 86 | |
| 77 | 87 | test("a durable invalid-key marker remains authoritative across legacy migration", () => { |
| @@ -98,7 +108,8 @@ test("a durable invalid-key marker remains authoritative across legacy migration | ||
| 98 | 108 | test("v2 page envelopes preserve arrivals while a legacy empty close can never clear favorites", () => { |
| 99 | 109 | const current = record(); |
| 100 | 110 | const opening = configuration.configurationPageState(current, "fr"); |
| 101 | - assert.deepEqual(opening, { schemaVersion: 2, hasKey: true, favorites: current.favorites, language: "fr" }); | |
| 111 | + assert.deepEqual(opening, { schemaVersion: 2, hasKey: true, favorites: current.favorites, | |
| 112 | + language: "fr", languagePreference: "auto" }); | |
| 102 | 113 | const update = { schemaVersion: 2, favorites: current.favorites, apiKeyUpdate: { schemaVersion: 1, action: "KEEP" } }; |
| 103 | 114 | const parsed = configuration.parseCloseFragment("pebblejs://close#" + encodeURIComponent(JSON.stringify(update))); |
| 104 | 115 | assert.deepEqual(configuration.applyConfigurationUpdate(current, parsed), current); |
| @@ -108,6 +119,53 @@ test("v2 page envelopes preserve arrivals while a legacy empty close can never c | ||
| 108 | 119 | assert.equal(configuration.areFavoritesSecretFree(current.favorites, "aaa", null), false); |
| 109 | 120 | }); |
| 110 | 121 | |
| 122 | +test("stored language preferences survive restoration while malformed values never become Automatic", () => { | |
| 123 | + for (const preference of ["auto", "en", "fr"]) { | |
| 124 | + const value = { ...record(), languagePreference: preference }, storage = storageFor(value); | |
| 125 | + assert.deepEqual(configuration.loadConfiguration(storage), value); | |
| 126 | + assert.equal(configuration.configurationPageState(configuration.loadConfiguration(storage), "en").languagePreference, preference); | |
| 127 | + } | |
| 128 | + for (const version of [1, 2, configuration.CONFIG_SCHEMA_VERSION]) { | |
| 129 | + for (const preference of ["fr-FR", "", null, false]) { | |
| 130 | + const storage = storageFor({ ...record(version), languagePreference: preference }); | |
| 131 | + const original = storage.getItem(configuration.CONFIG_STORAGE_KEY); | |
| 132 | + assert.equal(configuration.loadConfiguration(storage), null); | |
| 133 | + assert.equal(storage.getItem(configuration.CONFIG_STORAGE_KEY), original); | |
| 134 | + } | |
| 135 | + } | |
| 136 | + const missing = record(); delete missing.languagePreference; | |
| 137 | + assert.equal(configuration.loadConfiguration(storageFor(missing)), null); | |
| 138 | +}); | |
| 139 | + | |
| 140 | +test("legacy saves preserve an override, explicit Automatic removes it, and invalid preferences reject the entire update", () => { | |
| 141 | + const current = { ...record(), languagePreference: "fr" }; | |
| 142 | + const update = { schemaVersion: 2, favorites: current.favorites, apiKeyUpdate: { schemaVersion: 1, action: "KEEP" } }; | |
| 143 | + const parse = (value) => configuration.parseCloseFragment(encodeURIComponent(JSON.stringify(value))); | |
| 144 | + assert.deepEqual(configuration.applyConfigurationUpdate(current, parse(update)), current); | |
| 145 | + assert.equal(configuration.applyConfigurationUpdate(current, parse({ ...update, languagePreference: "auto" })).languagePreference, "auto"); | |
| 146 | + for (const preference of ["fr-FR", "", null, false, undefined]) { | |
| 147 | + const invalid = { ...update, favorites: [], apiKeyUpdate: { schemaVersion: 1, action: "REMOVE" }, | |
| 148 | + languagePreference: preference }; | |
| 149 | + assert.equal(configuration.applyConfigurationUpdate(current, invalid), null); | |
| 150 | + if (preference !== undefined) assert.equal(parse(invalid), null); | |
| 151 | + } | |
| 152 | + assert.equal(current.languagePreference, "fr"); | |
| 153 | + assert.deepEqual(current.favorites, record().favorites); | |
| 154 | + assert.equal(current.primApiKey, KEY); | |
| 155 | +}); | |
| 156 | + | |
| 157 | +test("Automatic retains French normalization and English fallback without consulting the watch for overrides", () => { | |
| 158 | + for (const [language, expected] of [["fr_FR", "fr"], ["FR-ca", "fr"], ["de_DE", "en"], [undefined, "en"]]) { | |
| 159 | + assert.equal(configuration.effectiveWatchLanguage({ getActiveWatchInfo: () => ({ language }) }, "auto"), expected); | |
| 160 | + } | |
| 161 | + assert.equal(configuration.effectiveWatchLanguage({}, "auto"), "en"); | |
| 162 | + assert.equal(configuration.effectiveWatchLanguage({ getActiveWatchInfo: () => null }, "auto"), "en"); | |
| 163 | + const unavailable = { getActiveWatchInfo: () => { throw new Error("unavailable"); } }; | |
| 164 | + assert.equal(configuration.effectiveWatchLanguage(unavailable, "auto"), "en"); | |
| 165 | + assert.equal(configuration.effectiveWatchLanguage(unavailable, "en"), "en"); | |
| 166 | + assert.equal(configuration.effectiveWatchLanguage(unavailable, "fr"), "fr"); | |
| 167 | +}); | |
| 168 | + | |
| 111 | 169 | function overview(favorites) { |
| 112 | 170 | const { schemaVersion, requestId, favoriteId, ...snapshot } = fixture.result; |
| 113 | 171 | return { |
| @@ -9,10 +9,12 @@ const fixture = require("../../../fixtures/departures/foundation.json"); | |||
| 9 | const KEY = "journey-storage-fixture-key"; | 9 | const KEY = "journey-storage-fixture-key"; |
| 10 | const ARRIVAL = "plc_" + "a".repeat(43); | 10 | const ARRIVAL = "plc_" + "a".repeat(43); |
| 11 | const ROUTING = { monitoringRef: "STIF:StopPoint:Q:1:", lineRef: "STIF:Line::1:", destinationRef: "STIF:StopPoint:Q:2:" }; | 11 | const ROUTING = { monitoringRef: "STIF:StopPoint:Q:1:", lineRef: "STIF:Line::1:", destinationRef: "STIF:StopPoint:Q:2:" }; |
| 12 | -function record(version = 2) { | 12 | +function record(version = configuration.CONFIG_SCHEMA_VERSION) { |
| 13 | const favorite = { ...fixture.favorite, arrivalPlaceId: ARRIVAL, routing: { ...ROUTING } }; | 13 | const favorite = { ...fixture.favorite, arrivalPlaceId: ARRIVAL, routing: { ...ROUTING } }; |
| 14 | if (version === 1) delete favorite.arrivalPlaceId; | 14 | if (version === 1) delete favorite.arrivalPlaceId; |
| 15 | - return { schemaVersion: version, favorites: [favorite], primApiKey: KEY, keyStatus: C.KEY_STATUS.CONFIGURED }; | 15 | + const value = { schemaVersion: version, favorites: [favorite], primApiKey: KEY, keyStatus: C.KEY_STATUS.CONFIGURED }; |
| 16 | + if (version === configuration.CONFIG_SCHEMA_VERSION) value.languagePreference = "auto"; | ||
| 17 | + return value; | ||
| 16 | } | 18 | } |
| 17 | function storageFor(value) { | 19 | function storageFor(value) { |
| 18 | const storage = new FakeStorage(); | 20 | const storage = new FakeStorage(); |
| @@ -20,33 +22,37 @@ function storageFor(value) { | |||
| 20 | return storage; | 22 | return storage; |
| 21 | } | 23 | } |
| 22 | 24 | ||
| 23 | -test("legacy storage migrates once without losing presentation, credentials or routing", () => { | 25 | +test("both supported phone records migrate once to Automatic without losing favorites or credentials", () => { |
| 24 | - const legacy = record(1), storage = storageFor(legacy); | 26 | + for (const version of [1, 2]) { |
| 25 | - const expected = { ...legacy, schemaVersion: 2, favorites: [{ ...legacy.favorites[0], arrivalPlaceId: null }] }; | 27 | + const legacy = record(version), storage = storageFor(legacy); |
| 26 | - assert.deepEqual(configuration.loadConfiguration(storage), expected); | 28 | + const expected = { ...legacy, schemaVersion: configuration.CONFIG_SCHEMA_VERSION, languagePreference: "auto", |
| 27 | - assert.deepEqual(JSON.parse(storage.getItem(configuration.CONFIG_STORAGE_KEY)), expected); | 29 | + favorites: [{ ...legacy.favorites[0], arrivalPlaceId: version === 1 ? null : ARRIVAL }] }; |
| 28 | - const writes = storage.writes.length; | 30 | + assert.deepEqual(configuration.loadConfiguration(storage), expected); |
| 29 | - assert.deepEqual(configuration.loadConfiguration(storage), expected); | 31 | + assert.deepEqual(JSON.parse(storage.getItem(configuration.CONFIG_STORAGE_KEY)), expected); |
| 30 | - assert.equal(storage.writes.length, writes); | 32 | + const writes = storage.writes.length; |
| 31 | - assert.equal(configuration.isStoredConfiguration(legacy), false); | 33 | + assert.deepEqual(configuration.loadConfiguration(storage), expected); |
| 34 | + assert.equal(storage.writes.length, writes); | ||
| 35 | + assert.equal(configuration.isStoredConfiguration(legacy), false); | ||
| 36 | + } | ||
| 32 | }); | 37 | }); |
| 33 | 38 | ||
| 34 | test("storage never treats malformed or missing v2 arrivals as a legacy omission", () => { | 39 | test("storage never treats malformed or missing v2 arrivals as a legacy omission", () => { |
| 35 | - for (const version of [1, 2]) { | 40 | + for (const version of [1, 2, configuration.CONFIG_SCHEMA_VERSION]) { |
| 36 | const value = record(version); | 41 | const value = record(version); |
| 37 | value.favorites[0].arrivalPlaceId = "not-a-place"; | 42 | value.favorites[0].arrivalPlaceId = "not-a-place"; |
| 38 | const storage = storageFor(value), before = storage.getItem(configuration.CONFIG_STORAGE_KEY); | 43 | const storage = storageFor(value), before = storage.getItem(configuration.CONFIG_STORAGE_KEY); |
| 39 | assert.equal(configuration.loadConfiguration(storage), null); | 44 | assert.equal(configuration.loadConfiguration(storage), null); |
| 40 | assert.equal(storage.getItem(configuration.CONFIG_STORAGE_KEY), before); | 45 | assert.equal(storage.getItem(configuration.CONFIG_STORAGE_KEY), before); |
| 41 | } | 46 | } |
| 42 | - const value = record(); delete value.favorites[0].arrivalPlaceId; | 47 | + const value = record(2); delete value.favorites[0].arrivalPlaceId; |
| 43 | assert.equal(configuration.loadConfiguration(storageFor(value)), null); | 48 | assert.equal(configuration.loadConfiguration(storageFor(value)), null); |
| 44 | const inherited = record(); delete inherited.favorites[0].arrivalPlaceId; | 49 | const inherited = record(); delete inherited.favorites[0].arrivalPlaceId; |
| 45 | Object.setPrototypeOf(inherited.favorites[0], { arrivalPlaceId: ARRIVAL }); | 50 | Object.setPrototypeOf(inherited.favorites[0], { arrivalPlaceId: ARRIVAL }); |
| 46 | assert.equal(configuration.isStoredConfiguration(inherited), false); | 51 | assert.equal(configuration.isStoredConfiguration(inherited), false); |
| 47 | const explicit = record(1); | 52 | const explicit = record(1); |
| 48 | explicit.favorites[0].arrivalPlaceId = ARRIVAL; | 53 | explicit.favorites[0].arrivalPlaceId = ARRIVAL; |
| 49 | - assert.deepEqual(configuration.loadConfiguration(storageFor(explicit)), { ...explicit, schemaVersion: 2 }); | 54 | + assert.deepEqual(configuration.loadConfiguration(storageFor(explicit)), |
| 55 | + { ...explicit, schemaVersion: configuration.CONFIG_SCHEMA_VERSION, languagePreference: "auto" }); | ||
| 50 | }); | 56 | }); |
| 51 | 57 | ||
| 52 | test("routing recovery does not erase an explicit arrival and rejects unrelated corruption", () => { | 58 | test("routing recovery does not erase an explicit arrival and rejects unrelated corruption", () => { |
| @@ -57,21 +63,25 @@ test("routing recovery does not erase an explicit arrival and rejects unrelated | |||
| 57 | assert.equal(configuration.loadConfiguration(storageFor(value)), null); | 63 | assert.equal(configuration.loadConfiguration(storageFor(value)), null); |
| 58 | }); | 64 | }); |
| 59 | 65 | ||
| 60 | -test("migration readback failure restores the exact old bytes and exposes no writable configuration", () => { | 66 | +test("migration readback failure restores either old format and exposes no writable configuration", () => { |
| 61 | - const storage = storageFor(record(1)), original = storage.getItem(configuration.CONFIG_STORAGE_KEY); | 67 | + for (const version of [1, 2]) { |
| 62 | - const write = storage.setItem; | 68 | + const storage = storageFor(record(version)), original = storage.getItem(configuration.CONFIG_STORAGE_KEY); |
| 63 | - let fail = true; | 69 | + const write = storage.setItem; |
| 64 | - storage.setItem = function (key, value) { | 70 | + let fail = true; |
| 65 | - if (key === configuration.CONFIG_STORAGE_KEY && fail) { | 71 | + storage.setItem = function (key, value) { |
| 66 | - fail = false; | 72 | + if (key === configuration.CONFIG_STORAGE_KEY && fail) { |
| 67 | - write.call(this, key, "corrupted-write"); | 73 | + fail = false; |
| 68 | - } else write.call(this, key, value); | 74 | + write.call(this, key, "corrupted-write"); |
| 69 | - }; | 75 | + } else write.call(this, key, value); |
| 70 | - assert.equal(configuration.loadConfiguration(storage), null); | 76 | + }; |
| 71 | - assert.equal(storage.getItem(configuration.CONFIG_STORAGE_KEY), original); | 77 | + assert.equal(configuration.loadConfiguration(storage), null); |
| 72 | - assert.equal(configuration.applyConfigurationUpdate(null, { schemaVersion: 2, favorites: [], | 78 | + assert.equal(storage.getItem(configuration.CONFIG_STORAGE_KEY), original); |
| 73 | - apiKeyUpdate: { schemaVersion: 1, action: "KEEP" } }), null); | 79 | + assert.equal(configuration.applyConfigurationUpdate(null, { schemaVersion: 2, favorites: [], |
| 74 | - assert.equal(configuration.loadConfiguration(storage).favorites[0].arrivalPlaceId, null); | 80 | + apiKeyUpdate: { schemaVersion: 1, action: "KEEP" } }), null); |
| 81 | + const restored = configuration.loadConfiguration(storage); | ||
| 82 | + assert.equal(restored.favorites[0].arrivalPlaceId, version === 1 ? null : ARRIVAL); | ||
| 83 | + assert.equal(restored.languagePreference, "auto"); | ||
| 84 | + } | ||
| 75 | }); | 85 | }); |
| 76 | 86 | ||
| 77 | test("a durable invalid-key marker remains authoritative across legacy migration", () => { | 87 | test("a durable invalid-key marker remains authoritative across legacy migration", () => { |
| @@ -98,7 +108,8 @@ test("a durable invalid-key marker remains authoritative across legacy migration | |||
| 98 | test("v2 page envelopes preserve arrivals while a legacy empty close can never clear favorites", () => { | 108 | test("v2 page envelopes preserve arrivals while a legacy empty close can never clear favorites", () => { |
| 99 | const current = record(); | 109 | const current = record(); |
| 100 | const opening = configuration.configurationPageState(current, "fr"); | 110 | const opening = configuration.configurationPageState(current, "fr"); |
| 101 | - assert.deepEqual(opening, { schemaVersion: 2, hasKey: true, favorites: current.favorites, language: "fr" }); | 111 | + assert.deepEqual(opening, { schemaVersion: 2, hasKey: true, favorites: current.favorites, |
| 112 | + language: "fr", languagePreference: "auto" }); | ||
| 102 | const update = { schemaVersion: 2, favorites: current.favorites, apiKeyUpdate: { schemaVersion: 1, action: "KEEP" } }; | 113 | const update = { schemaVersion: 2, favorites: current.favorites, apiKeyUpdate: { schemaVersion: 1, action: "KEEP" } }; |
| 103 | const parsed = configuration.parseCloseFragment("pebblejs://close#" + encodeURIComponent(JSON.stringify(update))); | 114 | const parsed = configuration.parseCloseFragment("pebblejs://close#" + encodeURIComponent(JSON.stringify(update))); |
| 104 | assert.deepEqual(configuration.applyConfigurationUpdate(current, parsed), current); | 115 | assert.deepEqual(configuration.applyConfigurationUpdate(current, parsed), current); |
| @@ -108,6 +119,53 @@ test("v2 page envelopes preserve arrivals while a legacy empty close can never c | |||
| 108 | assert.equal(configuration.areFavoritesSecretFree(current.favorites, "aaa", null), false); | 119 | assert.equal(configuration.areFavoritesSecretFree(current.favorites, "aaa", null), false); |
| 109 | }); | 120 | }); |
| 110 | 121 | ||
| 122 | +test("stored language preferences survive restoration while malformed values never become Automatic", () => { | ||
| 123 | + for (const preference of ["auto", "en", "fr"]) { | ||
| 124 | + const value = { ...record(), languagePreference: preference }, storage = storageFor(value); | ||
| 125 | + assert.deepEqual(configuration.loadConfiguration(storage), value); | ||
| 126 | + assert.equal(configuration.configurationPageState(configuration.loadConfiguration(storage), "en").languagePreference, preference); | ||
| 127 | + } | ||
| 128 | + for (const version of [1, 2, configuration.CONFIG_SCHEMA_VERSION]) { | ||
| 129 | + for (const preference of ["fr-FR", "", null, false]) { | ||
| 130 | + const storage = storageFor({ ...record(version), languagePreference: preference }); | ||
| 131 | + const original = storage.getItem(configuration.CONFIG_STORAGE_KEY); | ||
| 132 | + assert.equal(configuration.loadConfiguration(storage), null); | ||
| 133 | + assert.equal(storage.getItem(configuration.CONFIG_STORAGE_KEY), original); | ||
| 134 | + } | ||
| 135 | + } | ||
| 136 | + const missing = record(); delete missing.languagePreference; | ||
| 137 | + assert.equal(configuration.loadConfiguration(storageFor(missing)), null); | ||
| 138 | +}); | ||
| 139 | + | ||
| 140 | +test("legacy saves preserve an override, explicit Automatic removes it, and invalid preferences reject the entire update", () => { | ||
| 141 | + const current = { ...record(), languagePreference: "fr" }; | ||
| 142 | + const update = { schemaVersion: 2, favorites: current.favorites, apiKeyUpdate: { schemaVersion: 1, action: "KEEP" } }; | ||
| 143 | + const parse = (value) => configuration.parseCloseFragment(encodeURIComponent(JSON.stringify(value))); | ||
| 144 | + assert.deepEqual(configuration.applyConfigurationUpdate(current, parse(update)), current); | ||
| 145 | + assert.equal(configuration.applyConfigurationUpdate(current, parse({ ...update, languagePreference: "auto" })).languagePreference, "auto"); | ||
| 146 | + for (const preference of ["fr-FR", "", null, false, undefined]) { | ||
| 147 | + const invalid = { ...update, favorites: [], apiKeyUpdate: { schemaVersion: 1, action: "REMOVE" }, | ||
| 148 | + languagePreference: preference }; | ||
| 149 | + assert.equal(configuration.applyConfigurationUpdate(current, invalid), null); | ||
| 150 | + if (preference !== undefined) assert.equal(parse(invalid), null); | ||
| 151 | + } | ||
| 152 | + assert.equal(current.languagePreference, "fr"); | ||
| 153 | + assert.deepEqual(current.favorites, record().favorites); | ||
| 154 | + assert.equal(current.primApiKey, KEY); | ||
| 155 | +}); | ||
| 156 | + | ||
| 157 | +test("Automatic retains French normalization and English fallback without consulting the watch for overrides", () => { | ||
| 158 | + for (const [language, expected] of [["fr_FR", "fr"], ["FR-ca", "fr"], ["de_DE", "en"], [undefined, "en"]]) { | ||
| 159 | + assert.equal(configuration.effectiveWatchLanguage({ getActiveWatchInfo: () => ({ language }) }, "auto"), expected); | ||
| 160 | + } | ||
| 161 | + assert.equal(configuration.effectiveWatchLanguage({}, "auto"), "en"); | ||
| 162 | + assert.equal(configuration.effectiveWatchLanguage({ getActiveWatchInfo: () => null }, "auto"), "en"); | ||
| 163 | + const unavailable = { getActiveWatchInfo: () => { throw new Error("unavailable"); } }; | ||
| 164 | + assert.equal(configuration.effectiveWatchLanguage(unavailable, "auto"), "en"); | ||
| 165 | + assert.equal(configuration.effectiveWatchLanguage(unavailable, "en"), "en"); | ||
| 166 | + assert.equal(configuration.effectiveWatchLanguage(unavailable, "fr"), "fr"); | ||
| 167 | +}); | ||
| 168 | + | ||
| 111 | function overview(favorites) { | 169 | function overview(favorites) { |
| 112 | const { schemaVersion, requestId, favoriteId, ...snapshot } = fixture.result; | 170 | const { schemaVersion, requestId, favoriteId, ...snapshot } = fixture.result; |
| 113 | return { | 171 | return { |
modified
packages/config-page/index.html +22 -0 | @@ -124,6 +124,28 @@ | ||
| 124 | 124 | </div> |
| 125 | 125 | </details> |
| 126 | 126 | |
| 127 | + <details class="settings-section" id="language-section" aria-labelledby="language-title"> | |
| 128 | + <summary> | |
| 129 | + <span class="section-icon" aria-hidden="true"> | |
| 130 | + <svg viewBox="0 0 24 24" fill="none" focusable="false"><circle cx="12" cy="12" r="9"></circle><path d="M3 12h18M12 3c2.5 2.6 3.9 5.7 3.9 9s-1.4 6.4-3.9 9c-2.5-2.6-3.9-5.7-3.9-9S9.5 5.6 12 3Z"></path></svg> | |
| 131 | + </span> | |
| 132 | + <span class="summary-copy"> | |
| 133 | + <h2 id="language-title">Watch app language</h2> | |
| 134 | + </span> | |
| 135 | + <span class="disclosure" aria-hidden="true"></span> | |
| 136 | + </summary> | |
| 137 | + <div class="section-body"> | |
| 138 | + <label class="field-label" for="language-select" id="language-label"></label> | |
| 139 | + <select id="language-select"> | |
| 140 | + <option id="language-option-auto" value="auto">Automatic</option> | |
| 141 | + <option id="language-option-french" value="fr">Français</option> | |
| 142 | + <option id="language-option-english" value="en">English</option> | |
| 143 | + </select> | |
| 144 | + <p class="support" id="language-hint"></p> | |
| 145 | + <p class="support" id="language-unsupported" role="status" hidden></p> | |
| 146 | + </div> | |
| 147 | + </details> | |
| 148 | + | |
| 127 | 149 | <details class="settings-section" id="sync-section" aria-labelledby="sync-title"> |
| 128 | 150 | <summary> |
| 129 | 151 | <span class="section-icon" aria-hidden="true"> |
| @@ -124,6 +124,28 @@ | |||
| 124 | </div> | 124 | </div> |
| 125 | </details> | 125 | </details> |
| 126 | 126 | ||
| 127 | + <details class="settings-section" id="language-section" aria-labelledby="language-title"> | ||
| 128 | + <summary> | ||
| 129 | + <span class="section-icon" aria-hidden="true"> | ||
| 130 | + <svg viewBox="0 0 24 24" fill="none" focusable="false"><circle cx="12" cy="12" r="9"></circle><path d="M3 12h18M12 3c2.5 2.6 3.9 5.7 3.9 9s-1.4 6.4-3.9 9c-2.5-2.6-3.9-5.7-3.9-9S9.5 5.6 12 3Z"></path></svg> | ||
| 131 | + </span> | ||
| 132 | + <span class="summary-copy"> | ||
| 133 | + <h2 id="language-title">Watch app language</h2> | ||
| 134 | + </span> | ||
| 135 | + <span class="disclosure" aria-hidden="true"></span> | ||
| 136 | + </summary> | ||
| 137 | + <div class="section-body"> | ||
| 138 | + <label class="field-label" for="language-select" id="language-label"></label> | ||
| 139 | + <select id="language-select"> | ||
| 140 | + <option id="language-option-auto" value="auto">Automatic</option> | ||
| 141 | + <option id="language-option-french" value="fr">Français</option> | ||
| 142 | + <option id="language-option-english" value="en">English</option> | ||
| 143 | + </select> | ||
| 144 | + <p class="support" id="language-hint"></p> | ||
| 145 | + <p class="support" id="language-unsupported" role="status" hidden></p> | ||
| 146 | + </div> | ||
| 147 | + </details> | ||
| 148 | + | ||
| 127 | <details class="settings-section" id="sync-section" aria-labelledby="sync-title"> | 149 | <details class="settings-section" id="sync-section" aria-labelledby="sync-title"> |
| 128 | <summary> | 150 | <summary> |
| 129 | <span class="section-icon" aria-hidden="true"> | 151 | <span class="section-icon" aria-hidden="true"> |
modified
packages/config-page/src/config-core.js +77 -10 | @@ -11,8 +11,8 @@ | ||
| 11 | 11 | // |
| 12 | 12 | // Canonical contract: packages/contracts/src/index.ts. Domain objects, the |
| 13 | 13 | // favorites nested inside every envelope, and ApiKeyUpdate keep |
| 14 | -// SCHEMA_VERSION 1. Only the stored configuration and the page open/close | |
| 15 | -// envelopes use CONFIGURATION_VERSION 2; a close fragment on the old version | |
| 14 | +// SCHEMA_VERSION 1. Page open/close envelopes use CONFIGURATION_VERSION 2; | |
| 15 | +// the phone versions its stored record separately. A close on the old version | |
| 16 | 16 | // is always rejected by the phone, so an outdated page can never wipe the |
| 17 | 17 | // stored configuration. The mirrored constants below are asserted equal to |
| 18 | 18 | // the canonical module by test/config-page.test.ts; never change one side |
| @@ -26,10 +26,17 @@ import { JOURNEY_LIMITS, arrivalLabel } from "./generated/journey-patterns.js"; | ||
| 26 | 26 | |
| 27 | 27 | export const SCHEMA_VERSION = 1; |
| 28 | 28 | |
| 29 | -// Version of the stored configuration and of the opening/closing page | |
| 30 | -// envelopes only. Never used for domain objects. | |
| 29 | +// Version of the opening/closing page envelopes only. The phone versions its | |
| 30 | +// stored record separately. Never used for domain objects. | |
| 31 | 31 | export const CONFIGURATION_VERSION = 2; |
| 32 | 32 | |
| 33 | +// Phone-side watch-language preference carried by the page envelopes. "auto" | |
| 34 | +// follows the watch system language, "en"/"fr" force the app language. This is | |
| 35 | +// distinct from the resolved language ("en"/"fr") that copy and the watch | |
| 36 | +// synchronization already use: the preference is the stored intent, the | |
| 37 | +// language is what the phone resolves from it. | |
| 38 | +export const LANGUAGE_PREFERENCES = ["auto", "en", "fr"]; | |
| 39 | + | |
| 33 | 40 | // Journey place identities are catalog-computed plc_ + SHA-256 base64url; |
| 34 | 41 | // the page only validates their form. |
| 35 | 42 | const PLACE_ID = /^plc_[A-Za-z0-9_-]{43}$/; |
| @@ -172,6 +179,13 @@ export const COPY = { | ||
| 172 | 179 | syncCaption: "Transfer options", |
| 173 | 180 | syncHint: "The watch normally receives only what changed. Tick this to resend everything if the watch shows outdated favorites.", |
| 174 | 181 | forceSyncLabel: "Force a full synchronization at the next save", |
| 182 | + languageTitle: "Watch app language", | |
| 183 | + languageLabel: "Watch app language", | |
| 184 | + languageOptionAuto: "Automatic", | |
| 185 | + languageOptionFrench: "Français", | |
| 186 | + languageOptionEnglish: "English", | |
| 187 | + languageHint: "Automatic follows the watch system language. The choice applies to the watch app when you save.", | |
| 188 | + languageUnsupported: "Update Lapin Futé on your phone to change the watch app language.", | |
| 175 | 189 | saveTooLarge: "These settings are too large to transfer. Remove the favorite with the longest entry and add it again.", |
| 176 | 190 | configurationUpgradeRequired: "Update Lapin Futé on your Pebble to edit these settings.", |
| 177 | 191 | }, |
| @@ -241,6 +255,13 @@ export const COPY = { | ||
| 241 | 255 | syncCaption: "Options de transfert", |
| 242 | 256 | syncHint: "La montre ne reçoit normalement que les changements. Cochez cette case pour tout renvoyer si la montre affiche des favoris obsolètes.", |
| 243 | 257 | forceSyncLabel: "Forcer une synchronisation complète au prochain enregistrement", |
| 258 | + languageTitle: "Langue de l’application sur la montre", | |
| 259 | + languageLabel: "Langue de l’application sur la montre", | |
| 260 | + languageOptionAuto: "Automatique", | |
| 261 | + languageOptionFrench: "Français", | |
| 262 | + languageOptionEnglish: "English", | |
| 263 | + languageHint: "Automatique suit la langue système de la montre. Le choix s’applique à l’application lorsque vous enregistrez.", | |
| 264 | + languageUnsupported: "Mettez à jour Lapin Futé sur votre téléphone pour changer la langue de l’application sur la montre.", | |
| 244 | 265 | saveTooLarge: "Ces réglages sont trop volumineux pour être transférés. Supprimez le favori comportant l’entrée la plus longue, puis rajoutez-le.", |
| 245 | 266 | configurationUpgradeRequired: "Mettez à jour Lapin Futé sur votre Pebble pour modifier ces réglages.", |
| 246 | 267 | }, |
| @@ -458,6 +479,7 @@ function renumber(favorites) { | ||
| 458 | 479 | // --- Opening fragment -------------------------------------------------------- |
| 459 | 480 | // Expected shapes (all values non-secret): |
| 460 | 481 | // #<encodeURIComponent(JSON.stringify({ schemaVersion: 2, hasKey, favorites, language }))> |
| 482 | +// #<encodeURIComponent(JSON.stringify({ schemaVersion: 2, hasKey, favorites, language, languagePreference }))> | |
| 461 | 483 | // #<encodeURIComponent(JSON.stringify({ hasKey, favorites, language }))> (old phone) |
| 462 | 484 | // hasKey is the only credential-related field; the stored key itself is never |
| 463 | 485 | // present. A complete v2 envelope with only valid favorites opens an editable |
| @@ -465,21 +487,37 @@ function renumber(favorites) { | ||
| 465 | 487 | // display only: it is never hydrated, never converted, and never editable — |
| 466 | 488 | // the watch app must be updated. An absent or malformed fragment is likewise |
| 467 | 489 | // non-editable; previews always pass a real v2 envelope. |
| 490 | +// | |
| 491 | +// languagePreference is additive and strictly optional: a launch that omits it | |
| 492 | +// comes from a phone without preference support, so the page defaults to | |
| 493 | +// Automatic, keeps the selector disabled, and its close payload omits the | |
| 494 | +// field so the phone preserves the stored preference. A present but invalid | |
| 495 | +// value (including null) rejects the whole envelope — it is never coerced. | |
| 468 | 496 | |
| 469 | 497 | export const MAX_OPENING_FRAGMENT_LENGTH = 32768; |
| 470 | -const OPENING_FIELDS = ["schemaVersion", "hasKey", "favorites", "language"]; | |
| 498 | +const OPENING_FIELDS = ["schemaVersion", "hasKey", "favorites", "language", "languagePreference"]; | |
| 471 | 499 | |
| 472 | 500 | function emptyOpeningState() { |
| 473 | - return { hasKey: false, language: "en", locale: "en", favorites: [], editable: false }; | |
| 501 | + return { | |
| 502 | + hasKey: false, | |
| 503 | + language: "en", | |
| 504 | + locale: "en", | |
| 505 | + favorites: [], | |
| 506 | + editable: false, | |
| 507 | + languagePreference: "auto", | |
| 508 | + languagePreferenceSupported: false, | |
| 509 | + }; | |
| 474 | 510 | } |
| 475 | 511 | |
| 476 | -function openingState(hasKey, language, favorites, editable) { | |
| 512 | +function openingState(hasKey, language, favorites, editable, languagePreference = undefined) { | |
| 477 | 513 | return { |
| 478 | 514 | hasKey, |
| 479 | 515 | language, |
| 480 | 516 | locale: selectLocale(language), |
| 481 | 517 | favorites, |
| 482 | 518 | editable, |
| 519 | + languagePreference: languagePreference === undefined ? "auto" : languagePreference, | |
| 520 | + languagePreferenceSupported: languagePreference !== undefined, | |
| 483 | 521 | }; |
| 484 | 522 | } |
| 485 | 523 | |
| @@ -501,6 +539,8 @@ export function parseConfigFragment(hash) { | ||
| 501 | 539 | || !Array.isArray(parsed.favorites) |
| 502 | 540 | || typeof parsed.language !== "string") return emptyOpeningState(); |
| 503 | 541 | if (!Object.keys(parsed).every((field) => OPENING_FIELDS.includes(field))) return emptyOpeningState(); |
| 542 | + if (parsed.languagePreference !== undefined | |
| 543 | + && !LANGUAGE_PREFERENCES.includes(parsed.languagePreference)) return emptyOpeningState(); | |
| 504 | 544 | if (parsed.schemaVersion === CONFIGURATION_VERSION) { |
| 505 | 545 | // An invalid v2 favorite never yields an editable subset: the session |
| 506 | 546 | // stays read-only, and only entries that are individually valid are |
| @@ -511,6 +551,7 @@ export function parseConfigFragment(hash) { | ||
| 511 | 551 | parsed.language, |
| 512 | 552 | parsed.favorites.filter(isPhoneFavorite).map(copyPhoneFavorite), |
| 513 | 553 | false, |
| 554 | + parsed.languagePreference, | |
| 514 | 555 | ); |
| 515 | 556 | } |
| 516 | 557 | return openingState( |
| @@ -518,10 +559,13 @@ export function parseConfigFragment(hash) { | ||
| 518 | 559 | parsed.language, |
| 519 | 560 | parsed.favorites.map(copyPhoneFavorite), |
| 520 | 561 | true, |
| 562 | + parsed.languagePreference, | |
| 521 | 563 | ); |
| 522 | 564 | } |
| 523 | 565 | if (parsed.schemaVersion === undefined || parsed.schemaVersion === SCHEMA_VERSION) { |
| 524 | 566 | // Old or unversioned phone: read-only favorites display, nothing else. |
| 567 | + // Such a phone never advertises the preference, and its close channel | |
| 568 | + // would reject the field, so it is never adopted here. | |
| 525 | 569 | return openingState( |
| 526 | 570 | parsed.hasKey, |
| 527 | 571 | parsed.language, |
| @@ -545,6 +589,12 @@ export function initialConfigState(query) { | ||
| 545 | 589 | // Fail closed: only an explicitly editable session (a complete v2 |
| 546 | 590 | // opening) may mutate anything or save. |
| 547 | 591 | editable: query.editable === true, |
| 592 | + // Normalized invariant: always an exact supported value. Strict rejection | |
| 593 | + // of invalid launch values already happened in parseConfigFragment. | |
| 594 | + languagePreference: LANGUAGE_PREFERENCES.includes(query.languagePreference) | |
| 595 | + ? query.languagePreference | |
| 596 | + : "auto", | |
| 597 | + languagePreferenceSupported: query.languagePreferenceSupported === true, | |
| 548 | 598 | }; |
| 549 | 599 | } |
| 550 | 600 | |
| @@ -643,6 +693,14 @@ export function reduceConfigState(state, action) { | ||
| 643 | 693 | if (!favoritesFitCloseBound(candidate)) return state; |
| 644 | 694 | return { ...state, favorites: candidate }; |
| 645 | 695 | } |
| 696 | + case "language-preference": { | |
| 697 | + // Strict: only an advertised, editable session may change the | |
| 698 | + // preference, and only to an exact supported value — never coerced. | |
| 699 | + if (state.editable !== true || state.languagePreferenceSupported !== true) return state; | |
| 700 | + if (!LANGUAGE_PREFERENCES.includes(action.value)) return state; | |
| 701 | + if (action.value === state.languagePreference) return state; | |
| 702 | + return { ...state, languagePreference: action.value }; | |
| 703 | + } | |
| 646 | 704 | case "force-full-sync": |
| 647 | 705 | if (state.editable !== true) return state; |
| 648 | 706 | return { ...state, forceFullSync: action.value === true }; |
| @@ -694,6 +752,14 @@ export function planConfigResult(state) { | ||
| 694 | 752 | apiKeyUpdate, |
| 695 | 753 | favorites: state.favorites.map(stampFavorite), |
| 696 | 754 | }; |
| 755 | + // The preference rides along only when this launch advertised support: an | |
| 756 | + // absent field tells the phone to preserve the stored preference, while an | |
| 757 | + // explicit "auto" clears the override. Carrying it unconditionally would | |
| 758 | + // feed an unknown field to an older phone, which rejects the whole update. | |
| 759 | + if (state.languagePreferenceSupported === true | |
| 760 | + && LANGUAGE_PREFERENCES.includes(state.languagePreference)) { | |
| 761 | + payload.languagePreference = state.languagePreference; | |
| 762 | + } | |
| 697 | 763 | // One-shot full resync: absent unless explicitly requested on the settings page. |
| 698 | 764 | if (state.forceFullSync === true) payload.forceFullSync = true; |
| 699 | 765 | return { ok: true, payload }; |
| @@ -760,9 +826,9 @@ export function closePayloadFits(payload) { | ||
| 760 | 826 | // becomes six encoded characters (quotes behave identically; canonical |
| 761 | 827 | // boundedString excludes control characters, and every other byte inflates |
| 762 | 828 | // at most threefold). A 512-byte key of backslashes therefore bounds every |
| 763 | -// key the page can plan; the flag adds only a fixed tail. If this envelope | |
| 764 | -// fits, every real save payload fits too; the actual payload is still checked | |
| 765 | -// authoritatively at close time. | |
| 829 | +// key the page can plan; the preference and the flag add only a fixed tail. | |
| 830 | +// If this envelope fits, every real save payload fits too; the actual payload | |
| 831 | +// is still checked authoritatively at close time. | |
| 766 | 832 | |
| 767 | 833 | function favoritesFitCloseBound(favorites) { |
| 768 | 834 | return closePayloadFits({ |
| @@ -773,6 +839,7 @@ function favoritesFitCloseBound(favorites) { | ||
| 773 | 839 | value: "\\".repeat(LIMITS.apiKeyUtf8Bytes), |
| 774 | 840 | }, |
| 775 | 841 | favorites, |
| 842 | + languagePreference: "auto", | |
| 776 | 843 | forceFullSync: true, |
| 777 | 844 | }); |
| 778 | 845 | } |
| @@ -11,8 +11,8 @@ | |||
| 11 | // | 11 | // |
| 12 | // Canonical contract: packages/contracts/src/index.ts. Domain objects, the | 12 | // Canonical contract: packages/contracts/src/index.ts. Domain objects, the |
| 13 | // favorites nested inside every envelope, and ApiKeyUpdate keep | 13 | // favorites nested inside every envelope, and ApiKeyUpdate keep |
| 14 | -// SCHEMA_VERSION 1. Only the stored configuration and the page open/close | 14 | +// SCHEMA_VERSION 1. Page open/close envelopes use CONFIGURATION_VERSION 2; |
| 15 | -// envelopes use CONFIGURATION_VERSION 2; a close fragment on the old version | 15 | +// the phone versions its stored record separately. A close on the old version |
| 16 | // is always rejected by the phone, so an outdated page can never wipe the | 16 | // is always rejected by the phone, so an outdated page can never wipe the |
| 17 | // stored configuration. The mirrored constants below are asserted equal to | 17 | // stored configuration. The mirrored constants below are asserted equal to |
| 18 | // the canonical module by test/config-page.test.ts; never change one side | 18 | // the canonical module by test/config-page.test.ts; never change one side |
| @@ -26,10 +26,17 @@ import { JOURNEY_LIMITS, arrivalLabel } from "./generated/journey-patterns.js"; | |||
| 26 | 26 | ||
| 27 | export const SCHEMA_VERSION = 1; | 27 | export const SCHEMA_VERSION = 1; |
| 28 | 28 | ||
| 29 | -// Version of the stored configuration and of the opening/closing page | 29 | +// Version of the opening/closing page envelopes only. The phone versions its |
| 30 | -// envelopes only. Never used for domain objects. | 30 | +// stored record separately. Never used for domain objects. |
| 31 | export const CONFIGURATION_VERSION = 2; | 31 | export const CONFIGURATION_VERSION = 2; |
| 32 | 32 | ||
| 33 | +// Phone-side watch-language preference carried by the page envelopes. "auto" | ||
| 34 | +// follows the watch system language, "en"/"fr" force the app language. This is | ||
| 35 | +// distinct from the resolved language ("en"/"fr") that copy and the watch | ||
| 36 | +// synchronization already use: the preference is the stored intent, the | ||
| 37 | +// language is what the phone resolves from it. | ||
| 38 | +export const LANGUAGE_PREFERENCES = ["auto", "en", "fr"]; | ||
| 39 | + | ||
| 33 | // Journey place identities are catalog-computed plc_ + SHA-256 base64url; | 40 | // Journey place identities are catalog-computed plc_ + SHA-256 base64url; |
| 34 | // the page only validates their form. | 41 | // the page only validates their form. |
| 35 | const PLACE_ID = /^plc_[A-Za-z0-9_-]{43}$/; | 42 | const PLACE_ID = /^plc_[A-Za-z0-9_-]{43}$/; |
| @@ -172,6 +179,13 @@ export const COPY = { | |||
| 172 | syncCaption: "Transfer options", | 179 | syncCaption: "Transfer options", |
| 173 | syncHint: "The watch normally receives only what changed. Tick this to resend everything if the watch shows outdated favorites.", | 180 | syncHint: "The watch normally receives only what changed. Tick this to resend everything if the watch shows outdated favorites.", |
| 174 | forceSyncLabel: "Force a full synchronization at the next save", | 181 | forceSyncLabel: "Force a full synchronization at the next save", |
| 182 | + languageTitle: "Watch app language", | ||
| 183 | + languageLabel: "Watch app language", | ||
| 184 | + languageOptionAuto: "Automatic", | ||
| 185 | + languageOptionFrench: "Français", | ||
| 186 | + languageOptionEnglish: "English", | ||
| 187 | + languageHint: "Automatic follows the watch system language. The choice applies to the watch app when you save.", | ||
| 188 | + languageUnsupported: "Update Lapin Futé on your phone to change the watch app language.", | ||
| 175 | saveTooLarge: "These settings are too large to transfer. Remove the favorite with the longest entry and add it again.", | 189 | saveTooLarge: "These settings are too large to transfer. Remove the favorite with the longest entry and add it again.", |
| 176 | configurationUpgradeRequired: "Update Lapin Futé on your Pebble to edit these settings.", | 190 | configurationUpgradeRequired: "Update Lapin Futé on your Pebble to edit these settings.", |
| 177 | }, | 191 | }, |
| @@ -241,6 +255,13 @@ export const COPY = { | |||
| 241 | syncCaption: "Options de transfert", | 255 | syncCaption: "Options de transfert", |
| 242 | syncHint: "La montre ne reçoit normalement que les changements. Cochez cette case pour tout renvoyer si la montre affiche des favoris obsolètes.", | 256 | syncHint: "La montre ne reçoit normalement que les changements. Cochez cette case pour tout renvoyer si la montre affiche des favoris obsolètes.", |
| 243 | forceSyncLabel: "Forcer une synchronisation complète au prochain enregistrement", | 257 | forceSyncLabel: "Forcer une synchronisation complète au prochain enregistrement", |
| 258 | + languageTitle: "Langue de l’application sur la montre", | ||
| 259 | + languageLabel: "Langue de l’application sur la montre", | ||
| 260 | + languageOptionAuto: "Automatique", | ||
| 261 | + languageOptionFrench: "Français", | ||
| 262 | + languageOptionEnglish: "English", | ||
| 263 | + languageHint: "Automatique suit la langue système de la montre. Le choix s’applique à l’application lorsque vous enregistrez.", | ||
| 264 | + languageUnsupported: "Mettez à jour Lapin Futé sur votre téléphone pour changer la langue de l’application sur la montre.", | ||
| 244 | saveTooLarge: "Ces réglages sont trop volumineux pour être transférés. Supprimez le favori comportant l’entrée la plus longue, puis rajoutez-le.", | 265 | saveTooLarge: "Ces réglages sont trop volumineux pour être transférés. Supprimez le favori comportant l’entrée la plus longue, puis rajoutez-le.", |
| 245 | configurationUpgradeRequired: "Mettez à jour Lapin Futé sur votre Pebble pour modifier ces réglages.", | 266 | configurationUpgradeRequired: "Mettez à jour Lapin Futé sur votre Pebble pour modifier ces réglages.", |
| 246 | }, | 267 | }, |
| @@ -458,6 +479,7 @@ function renumber(favorites) { | |||
| 458 | // --- Opening fragment -------------------------------------------------------- | 479 | // --- Opening fragment -------------------------------------------------------- |
| 459 | // Expected shapes (all values non-secret): | 480 | // Expected shapes (all values non-secret): |
| 460 | // #<encodeURIComponent(JSON.stringify({ schemaVersion: 2, hasKey, favorites, language }))> | 481 | // #<encodeURIComponent(JSON.stringify({ schemaVersion: 2, hasKey, favorites, language }))> |
| 482 | +// #<encodeURIComponent(JSON.stringify({ schemaVersion: 2, hasKey, favorites, language, languagePreference }))> | ||
| 461 | // #<encodeURIComponent(JSON.stringify({ hasKey, favorites, language }))> (old phone) | 483 | // #<encodeURIComponent(JSON.stringify({ hasKey, favorites, language }))> (old phone) |
| 462 | // hasKey is the only credential-related field; the stored key itself is never | 484 | // hasKey is the only credential-related field; the stored key itself is never |
| 463 | // present. A complete v2 envelope with only valid favorites opens an editable | 485 | // present. A complete v2 envelope with only valid favorites opens an editable |
| @@ -465,21 +487,37 @@ function renumber(favorites) { | |||
| 465 | // display only: it is never hydrated, never converted, and never editable — | 487 | // display only: it is never hydrated, never converted, and never editable — |
| 466 | // the watch app must be updated. An absent or malformed fragment is likewise | 488 | // the watch app must be updated. An absent or malformed fragment is likewise |
| 467 | // non-editable; previews always pass a real v2 envelope. | 489 | // non-editable; previews always pass a real v2 envelope. |
| 490 | +// | ||
| 491 | +// languagePreference is additive and strictly optional: a launch that omits it | ||
| 492 | +// comes from a phone without preference support, so the page defaults to | ||
| 493 | +// Automatic, keeps the selector disabled, and its close payload omits the | ||
| 494 | +// field so the phone preserves the stored preference. A present but invalid | ||
| 495 | +// value (including null) rejects the whole envelope — it is never coerced. | ||
| 468 | 496 | ||
| 469 | export const MAX_OPENING_FRAGMENT_LENGTH = 32768; | 497 | export const MAX_OPENING_FRAGMENT_LENGTH = 32768; |
| 470 | -const OPENING_FIELDS = ["schemaVersion", "hasKey", "favorites", "language"]; | 498 | +const OPENING_FIELDS = ["schemaVersion", "hasKey", "favorites", "language", "languagePreference"]; |
| 471 | 499 | ||
| 472 | function emptyOpeningState() { | 500 | function emptyOpeningState() { |
| 473 | - return { hasKey: false, language: "en", locale: "en", favorites: [], editable: false }; | 501 | + return { |
| 502 | + hasKey: false, | ||
| 503 | + language: "en", | ||
| 504 | + locale: "en", | ||
| 505 | + favorites: [], | ||
| 506 | + editable: false, | ||
| 507 | + languagePreference: "auto", | ||
| 508 | + languagePreferenceSupported: false, | ||
| 509 | + }; | ||
| 474 | } | 510 | } |
| 475 | 511 | ||
| 476 | -function openingState(hasKey, language, favorites, editable) { | 512 | +function openingState(hasKey, language, favorites, editable, languagePreference = undefined) { |
| 477 | return { | 513 | return { |
| 478 | hasKey, | 514 | hasKey, |
| 479 | language, | 515 | language, |
| 480 | locale: selectLocale(language), | 516 | locale: selectLocale(language), |
| 481 | favorites, | 517 | favorites, |
| 482 | editable, | 518 | editable, |
| 519 | + languagePreference: languagePreference === undefined ? "auto" : languagePreference, | ||
| 520 | + languagePreferenceSupported: languagePreference !== undefined, | ||
| 483 | }; | 521 | }; |
| 484 | } | 522 | } |
| 485 | 523 | ||
| @@ -501,6 +539,8 @@ export function parseConfigFragment(hash) { | |||
| 501 | || !Array.isArray(parsed.favorites) | 539 | || !Array.isArray(parsed.favorites) |
| 502 | || typeof parsed.language !== "string") return emptyOpeningState(); | 540 | || typeof parsed.language !== "string") return emptyOpeningState(); |
| 503 | if (!Object.keys(parsed).every((field) => OPENING_FIELDS.includes(field))) return emptyOpeningState(); | 541 | if (!Object.keys(parsed).every((field) => OPENING_FIELDS.includes(field))) return emptyOpeningState(); |
| 542 | + if (parsed.languagePreference !== undefined | ||
| 543 | + && !LANGUAGE_PREFERENCES.includes(parsed.languagePreference)) return emptyOpeningState(); | ||
| 504 | if (parsed.schemaVersion === CONFIGURATION_VERSION) { | 544 | if (parsed.schemaVersion === CONFIGURATION_VERSION) { |
| 505 | // An invalid v2 favorite never yields an editable subset: the session | 545 | // An invalid v2 favorite never yields an editable subset: the session |
| 506 | // stays read-only, and only entries that are individually valid are | 546 | // stays read-only, and only entries that are individually valid are |
| @@ -511,6 +551,7 @@ export function parseConfigFragment(hash) { | |||
| 511 | parsed.language, | 551 | parsed.language, |
| 512 | parsed.favorites.filter(isPhoneFavorite).map(copyPhoneFavorite), | 552 | parsed.favorites.filter(isPhoneFavorite).map(copyPhoneFavorite), |
| 513 | false, | 553 | false, |
| 554 | + parsed.languagePreference, | ||
| 514 | ); | 555 | ); |
| 515 | } | 556 | } |
| 516 | return openingState( | 557 | return openingState( |
| @@ -518,10 +559,13 @@ export function parseConfigFragment(hash) { | |||
| 518 | parsed.language, | 559 | parsed.language, |
| 519 | parsed.favorites.map(copyPhoneFavorite), | 560 | parsed.favorites.map(copyPhoneFavorite), |
| 520 | true, | 561 | true, |
| 562 | + parsed.languagePreference, | ||
| 521 | ); | 563 | ); |
| 522 | } | 564 | } |
| 523 | if (parsed.schemaVersion === undefined || parsed.schemaVersion === SCHEMA_VERSION) { | 565 | if (parsed.schemaVersion === undefined || parsed.schemaVersion === SCHEMA_VERSION) { |
| 524 | // Old or unversioned phone: read-only favorites display, nothing else. | 566 | // Old or unversioned phone: read-only favorites display, nothing else. |
| 567 | + // Such a phone never advertises the preference, and its close channel | ||
| 568 | + // would reject the field, so it is never adopted here. | ||
| 525 | return openingState( | 569 | return openingState( |
| 526 | parsed.hasKey, | 570 | parsed.hasKey, |
| 527 | parsed.language, | 571 | parsed.language, |
| @@ -545,6 +589,12 @@ export function initialConfigState(query) { | |||
| 545 | // Fail closed: only an explicitly editable session (a complete v2 | 589 | // Fail closed: only an explicitly editable session (a complete v2 |
| 546 | // opening) may mutate anything or save. | 590 | // opening) may mutate anything or save. |
| 547 | editable: query.editable === true, | 591 | editable: query.editable === true, |
| 592 | + // Normalized invariant: always an exact supported value. Strict rejection | ||
| 593 | + // of invalid launch values already happened in parseConfigFragment. | ||
| 594 | + languagePreference: LANGUAGE_PREFERENCES.includes(query.languagePreference) | ||
| 595 | + ? query.languagePreference | ||
| 596 | + : "auto", | ||
| 597 | + languagePreferenceSupported: query.languagePreferenceSupported === true, | ||
| 548 | }; | 598 | }; |
| 549 | } | 599 | } |
| 550 | 600 | ||
| @@ -643,6 +693,14 @@ export function reduceConfigState(state, action) { | |||
| 643 | if (!favoritesFitCloseBound(candidate)) return state; | 693 | if (!favoritesFitCloseBound(candidate)) return state; |
| 644 | return { ...state, favorites: candidate }; | 694 | return { ...state, favorites: candidate }; |
| 645 | } | 695 | } |
| 696 | + case "language-preference": { | ||
| 697 | + // Strict: only an advertised, editable session may change the | ||
| 698 | + // preference, and only to an exact supported value — never coerced. | ||
| 699 | + if (state.editable !== true || state.languagePreferenceSupported !== true) return state; | ||
| 700 | + if (!LANGUAGE_PREFERENCES.includes(action.value)) return state; | ||
| 701 | + if (action.value === state.languagePreference) return state; | ||
| 702 | + return { ...state, languagePreference: action.value }; | ||
| 703 | + } | ||
| 646 | case "force-full-sync": | 704 | case "force-full-sync": |
| 647 | if (state.editable !== true) return state; | 705 | if (state.editable !== true) return state; |
| 648 | return { ...state, forceFullSync: action.value === true }; | 706 | return { ...state, forceFullSync: action.value === true }; |
| @@ -694,6 +752,14 @@ export function planConfigResult(state) { | |||
| 694 | apiKeyUpdate, | 752 | apiKeyUpdate, |
| 695 | favorites: state.favorites.map(stampFavorite), | 753 | favorites: state.favorites.map(stampFavorite), |
| 696 | }; | 754 | }; |
| 755 | + // The preference rides along only when this launch advertised support: an | ||
| 756 | + // absent field tells the phone to preserve the stored preference, while an | ||
| 757 | + // explicit "auto" clears the override. Carrying it unconditionally would | ||
| 758 | + // feed an unknown field to an older phone, which rejects the whole update. | ||
| 759 | + if (state.languagePreferenceSupported === true | ||
| 760 | + && LANGUAGE_PREFERENCES.includes(state.languagePreference)) { | ||
| 761 | + payload.languagePreference = state.languagePreference; | ||
| 762 | + } | ||
| 697 | // One-shot full resync: absent unless explicitly requested on the settings page. | 763 | // One-shot full resync: absent unless explicitly requested on the settings page. |
| 698 | if (state.forceFullSync === true) payload.forceFullSync = true; | 764 | if (state.forceFullSync === true) payload.forceFullSync = true; |
| 699 | return { ok: true, payload }; | 765 | return { ok: true, payload }; |
| @@ -760,9 +826,9 @@ export function closePayloadFits(payload) { | |||
| 760 | // becomes six encoded characters (quotes behave identically; canonical | 826 | // becomes six encoded characters (quotes behave identically; canonical |
| 761 | // boundedString excludes control characters, and every other byte inflates | 827 | // boundedString excludes control characters, and every other byte inflates |
| 762 | // at most threefold). A 512-byte key of backslashes therefore bounds every | 828 | // at most threefold). A 512-byte key of backslashes therefore bounds every |
| 763 | -// key the page can plan; the flag adds only a fixed tail. If this envelope | 829 | +// key the page can plan; the preference and the flag add only a fixed tail. |
| 764 | -// fits, every real save payload fits too; the actual payload is still checked | 830 | +// If this envelope fits, every real save payload fits too; the actual payload |
| 765 | -// authoritatively at close time. | 831 | +// is still checked authoritatively at close time. |
| 766 | 832 | ||
| 767 | function favoritesFitCloseBound(favorites) { | 833 | function favoritesFitCloseBound(favorites) { |
| 768 | return closePayloadFits({ | 834 | return closePayloadFits({ |
| @@ -773,6 +839,7 @@ function favoritesFitCloseBound(favorites) { | |||
| 773 | value: "\\".repeat(LIMITS.apiKeyUtf8Bytes), | 839 | value: "\\".repeat(LIMITS.apiKeyUtf8Bytes), |
| 774 | }, | 840 | }, |
| 775 | favorites, | 841 | favorites, |
| 842 | + languagePreference: "auto", | ||
| 776 | forceFullSync: true, | 843 | forceFullSync: true, |
| 777 | }); | 844 | }); |
| 778 | } | 845 | } |
modified
packages/config-page/src/config-page.js +28 -1 | @@ -33,6 +33,8 @@ const elements = Object.fromEntries([ | ||
| 33 | 33 | "force-sync-label", "force-sync", "config-footer", "about-open", "about-view", "about-back", |
| 34 | 34 | "about-title", "about-en", "about-fr", |
| 35 | 35 | "add-caption", "sync-caption", |
| 36 | + "language-section", "language-title", "language-label", "language-select", "language-hint", | |
| 37 | + "language-unsupported", "language-option-auto", "language-option-french", "language-option-english", | |
| 36 | 38 | "arrival-step", "arrivals-label", "arrival-select", "journey-hint", "configuration-upgrade", |
| 37 | 39 | ].map((id) => [id.replaceAll("-", "_"), byId(id)])); |
| 38 | 40 | |
| @@ -119,6 +121,7 @@ function dispatch(action) { | ||
| 119 | 121 | state = reduceConfigState(state, action); |
| 120 | 122 | renderKey(); |
| 121 | 123 | renderFavorites(); |
| 124 | + renderLanguage(); | |
| 122 | 125 | } |
| 123 | 126 | |
| 124 | 127 | function applyCopy() { |
| @@ -142,6 +145,11 @@ function applyCopy() { | ||
| 142 | 145 | ["syncTitle", elements.sync_title], ["syncHint", elements.sync_hint], |
| 143 | 146 | ["syncCaption", elements.sync_caption], |
| 144 | 147 | ["forceSyncLabel", elements.force_sync_label], |
| 148 | + ["languageTitle", elements.language_title], ["languageLabel", elements.language_label], | |
| 149 | + ["languageHint", elements.language_hint], ["languageUnsupported", elements.language_unsupported], | |
| 150 | + ["languageOptionAuto", elements.language_option_auto], | |
| 151 | + ["languageOptionFrench", elements.language_option_french], | |
| 152 | + ["languageOptionEnglish", elements.language_option_english], | |
| 145 | 153 | ]) setText(element, copy[key]); |
| 146 | 154 | elements.key_input.placeholder = copy.keyPlaceholder; |
| 147 | 155 | elements.place_search.placeholder = copy.searchPlaceholder; |
| @@ -164,6 +172,19 @@ function renderKey() { | ||
| 164 | 172 | setText(elements.key_toggle, keyRevealed ? copy.keyHide : copy.keyShow); |
| 165 | 173 | } |
| 166 | 174 | |
| 175 | +// The selector restores the launch-carried preference and is offered only when | |
| 176 | +// the launch advertised support and the session is editable: an older phone | |
| 177 | +// keeps its stored preference because the close payload then omits the field, | |
| 178 | +// and a read-only session can never save. The compatibility note explains the | |
| 179 | +// disabled selector only where editing is otherwise possible. | |
| 180 | +function renderLanguage() { | |
| 181 | + elements.language_select.value = state.languagePreference; | |
| 182 | + const offered = state.editable === true && state.languagePreferenceSupported === true; | |
| 183 | + elements.language_select.disabled = !offered; | |
| 184 | + elements.language_unsupported.hidden = state.editable !== true | |
| 185 | + || state.languagePreferenceSupported === true; | |
| 186 | +} | |
| 187 | + | |
| 167 | 188 | function actionButton(label, disabled, action, className = "quiet") { |
| 168 | 189 | const button = document.createElement("button"); |
| 169 | 190 | button.type = "button"; |
| @@ -674,6 +695,10 @@ elements.force_sync.addEventListener("change", () => { | ||
| 674 | 695 | dispatch({ type: "force-full-sync", value: elements.force_sync.checked }); |
| 675 | 696 | }); |
| 676 | 697 | |
| 698 | +elements.language_select.addEventListener("change", () => { | |
| 699 | + dispatch({ type: "language-preference", value: elements.language_select.value }); | |
| 700 | +}); | |
| 701 | + | |
| 677 | 702 | elements.save.addEventListener("click", () => { |
| 678 | 703 | if (!state.editable) return; |
| 679 | 704 | const keyError = apiKeyError(state.keyDraft.value); |
| @@ -709,7 +734,7 @@ elements.save.addEventListener("click", () => { | ||
| 709 | 734 | }); |
| 710 | 735 | |
| 711 | 736 | // Section open/closed state is the only local persistence on this page: |
| 712 | -// four booleans under one namespaced key, keyed by the stable section IDs. | |
| 737 | +// five booleans under one namespaced key, keyed by the stable section IDs. | |
| 713 | 738 | // The key draft, favorites, and catalog data never reach storage, and |
| 714 | 739 | // unavailable or corrupt storage (private mode, blocked cookies, cleared |
| 715 | 740 | // origins) leaves the markup defaults in place and the page functional. |
| @@ -718,6 +743,7 @@ const sectionElements = [ | ||
| 718 | 743 | elements.prim_section, |
| 719 | 744 | elements.favorites_section, |
| 720 | 745 | elements.add_section, |
| 746 | + elements.language_section, | |
| 721 | 747 | elements.sync_section, |
| 722 | 748 | ]; |
| 723 | 749 | |
| @@ -766,6 +792,7 @@ window.addEventListener("pagehide", () => { | ||
| 766 | 792 | |
| 767 | 793 | applyCopy(); |
| 768 | 794 | renderKey(); |
| 795 | +renderLanguage(); | |
| 769 | 796 | renderFavorites(); |
| 770 | 797 | elements.configuration_upgrade.hidden = state.editable; |
| 771 | 798 | for (const element of [ |
| @@ -33,6 +33,8 @@ const elements = Object.fromEntries([ | |||
| 33 | "force-sync-label", "force-sync", "config-footer", "about-open", "about-view", "about-back", | 33 | "force-sync-label", "force-sync", "config-footer", "about-open", "about-view", "about-back", |
| 34 | "about-title", "about-en", "about-fr", | 34 | "about-title", "about-en", "about-fr", |
| 35 | "add-caption", "sync-caption", | 35 | "add-caption", "sync-caption", |
| 36 | + "language-section", "language-title", "language-label", "language-select", "language-hint", | ||
| 37 | + "language-unsupported", "language-option-auto", "language-option-french", "language-option-english", | ||
| 36 | "arrival-step", "arrivals-label", "arrival-select", "journey-hint", "configuration-upgrade", | 38 | "arrival-step", "arrivals-label", "arrival-select", "journey-hint", "configuration-upgrade", |
| 37 | ].map((id) => [id.replaceAll("-", "_"), byId(id)])); | 39 | ].map((id) => [id.replaceAll("-", "_"), byId(id)])); |
| 38 | 40 | ||
| @@ -119,6 +121,7 @@ function dispatch(action) { | |||
| 119 | state = reduceConfigState(state, action); | 121 | state = reduceConfigState(state, action); |
| 120 | renderKey(); | 122 | renderKey(); |
| 121 | renderFavorites(); | 123 | renderFavorites(); |
| 124 | + renderLanguage(); | ||
| 122 | } | 125 | } |
| 123 | 126 | ||
| 124 | function applyCopy() { | 127 | function applyCopy() { |
| @@ -142,6 +145,11 @@ function applyCopy() { | |||
| 142 | ["syncTitle", elements.sync_title], ["syncHint", elements.sync_hint], | 145 | ["syncTitle", elements.sync_title], ["syncHint", elements.sync_hint], |
| 143 | ["syncCaption", elements.sync_caption], | 146 | ["syncCaption", elements.sync_caption], |
| 144 | ["forceSyncLabel", elements.force_sync_label], | 147 | ["forceSyncLabel", elements.force_sync_label], |
| 148 | + ["languageTitle", elements.language_title], ["languageLabel", elements.language_label], | ||
| 149 | + ["languageHint", elements.language_hint], ["languageUnsupported", elements.language_unsupported], | ||
| 150 | + ["languageOptionAuto", elements.language_option_auto], | ||
| 151 | + ["languageOptionFrench", elements.language_option_french], | ||
| 152 | + ["languageOptionEnglish", elements.language_option_english], | ||
| 145 | ]) setText(element, copy[key]); | 153 | ]) setText(element, copy[key]); |
| 146 | elements.key_input.placeholder = copy.keyPlaceholder; | 154 | elements.key_input.placeholder = copy.keyPlaceholder; |
| 147 | elements.place_search.placeholder = copy.searchPlaceholder; | 155 | elements.place_search.placeholder = copy.searchPlaceholder; |
| @@ -164,6 +172,19 @@ function renderKey() { | |||
| 164 | setText(elements.key_toggle, keyRevealed ? copy.keyHide : copy.keyShow); | 172 | setText(elements.key_toggle, keyRevealed ? copy.keyHide : copy.keyShow); |
| 165 | } | 173 | } |
| 166 | 174 | ||
| 175 | +// The selector restores the launch-carried preference and is offered only when | ||
| 176 | +// the launch advertised support and the session is editable: an older phone | ||
| 177 | +// keeps its stored preference because the close payload then omits the field, | ||
| 178 | +// and a read-only session can never save. The compatibility note explains the | ||
| 179 | +// disabled selector only where editing is otherwise possible. | ||
| 180 | +function renderLanguage() { | ||
| 181 | + elements.language_select.value = state.languagePreference; | ||
| 182 | + const offered = state.editable === true && state.languagePreferenceSupported === true; | ||
| 183 | + elements.language_select.disabled = !offered; | ||
| 184 | + elements.language_unsupported.hidden = state.editable !== true | ||
| 185 | + || state.languagePreferenceSupported === true; | ||
| 186 | +} | ||
| 187 | + | ||
| 167 | function actionButton(label, disabled, action, className = "quiet") { | 188 | function actionButton(label, disabled, action, className = "quiet") { |
| 168 | const button = document.createElement("button"); | 189 | const button = document.createElement("button"); |
| 169 | button.type = "button"; | 190 | button.type = "button"; |
| @@ -674,6 +695,10 @@ elements.force_sync.addEventListener("change", () => { | |||
| 674 | dispatch({ type: "force-full-sync", value: elements.force_sync.checked }); | 695 | dispatch({ type: "force-full-sync", value: elements.force_sync.checked }); |
| 675 | }); | 696 | }); |
| 676 | 697 | ||
| 698 | +elements.language_select.addEventListener("change", () => { | ||
| 699 | + dispatch({ type: "language-preference", value: elements.language_select.value }); | ||
| 700 | +}); | ||
| 701 | + | ||
| 677 | elements.save.addEventListener("click", () => { | 702 | elements.save.addEventListener("click", () => { |
| 678 | if (!state.editable) return; | 703 | if (!state.editable) return; |
| 679 | const keyError = apiKeyError(state.keyDraft.value); | 704 | const keyError = apiKeyError(state.keyDraft.value); |
| @@ -709,7 +734,7 @@ elements.save.addEventListener("click", () => { | |||
| 709 | }); | 734 | }); |
| 710 | 735 | ||
| 711 | // Section open/closed state is the only local persistence on this page: | 736 | // Section open/closed state is the only local persistence on this page: |
| 712 | -// four booleans under one namespaced key, keyed by the stable section IDs. | 737 | +// five booleans under one namespaced key, keyed by the stable section IDs. |
| 713 | // The key draft, favorites, and catalog data never reach storage, and | 738 | // The key draft, favorites, and catalog data never reach storage, and |
| 714 | // unavailable or corrupt storage (private mode, blocked cookies, cleared | 739 | // unavailable or corrupt storage (private mode, blocked cookies, cleared |
| 715 | // origins) leaves the markup defaults in place and the page functional. | 740 | // origins) leaves the markup defaults in place and the page functional. |
| @@ -718,6 +743,7 @@ const sectionElements = [ | |||
| 718 | elements.prim_section, | 743 | elements.prim_section, |
| 719 | elements.favorites_section, | 744 | elements.favorites_section, |
| 720 | elements.add_section, | 745 | elements.add_section, |
| 746 | + elements.language_section, | ||
| 721 | elements.sync_section, | 747 | elements.sync_section, |
| 722 | ]; | 748 | ]; |
| 723 | 749 | ||
| @@ -766,6 +792,7 @@ window.addEventListener("pagehide", () => { | |||
| 766 | 792 | ||
| 767 | applyCopy(); | 793 | applyCopy(); |
| 768 | renderKey(); | 794 | renderKey(); |
| 795 | +renderLanguage(); | ||
| 769 | renderFavorites(); | 796 | renderFavorites(); |
| 770 | elements.configuration_upgrade.hidden = state.editable; | 797 | elements.configuration_upgrade.hidden = state.editable; |
| 771 | for (const element of [ | 798 | for (const element of [ |
modified
packages/config-page/src/generated/journey-patterns.js +2 -2 | @@ -4,8 +4,8 @@ const contractsModule = { exports: {} }; | ||
| 4 | 4 | "use strict"; |
| 5 | 5 | |
| 6 | 6 | var SCHEMA_VERSION = 1; |
| 7 | -// CONFIGURATION_VERSION 2 covers only the stored configuration record and the | |
| 8 | -// page open/close envelopes. Domain objects, nested favorites and ApiKeyUpdate | |
| 7 | +// CONFIGURATION_VERSION 2 covers the page open/close envelopes. The companion | |
| 8 | +// versions its phone-local stored record separately. Domain objects and ApiKeyUpdate | |
| 9 | 9 | // keep SCHEMA_VERSION 1. Versioning the envelope also protects an empty |
| 10 | 10 | // backup: a phone that cannot read v2 must never blank the configuration with |
| 11 | 11 | // a v1 close. |
| @@ -4,8 +4,8 @@ const contractsModule = { exports: {} }; | |||
| 4 | "use strict"; | 4 | "use strict"; |
| 5 | 5 | ||
| 6 | var SCHEMA_VERSION = 1; | 6 | var SCHEMA_VERSION = 1; |
| 7 | -// CONFIGURATION_VERSION 2 covers only the stored configuration record and the | 7 | +// CONFIGURATION_VERSION 2 covers the page open/close envelopes. The companion |
| 8 | -// page open/close envelopes. Domain objects, nested favorites and ApiKeyUpdate | 8 | +// versions its phone-local stored record separately. Domain objects and ApiKeyUpdate |
| 9 | // keep SCHEMA_VERSION 1. Versioning the envelope also protects an empty | 9 | // keep SCHEMA_VERSION 1. Versioning the envelope also protects an empty |
| 10 | // backup: a phone that cannot read v2 must never blank the configuration with | 10 | // backup: a phone that cannot read v2 must never blank the configuration with |
| 11 | // a v1 close. | 11 | // a v1 close. |
modified
packages/config-page/styles/config-page.css +1 -0 | @@ -263,6 +263,7 @@ button:disabled { cursor: default; opacity: 0.35; } | ||
| 263 | 263 | .check-row { display: flex; align-items: flex-start; gap: 0.75rem; min-height: 2.75rem; padding: 0.75rem; border-radius: 0.75rem; background: var(--page); color: var(--heading); font-size: 0.875rem; cursor: pointer; } |
| 264 | 264 | .check-row input { flex: 0 0 1.125rem; width: 1.125rem; min-height: 0; height: 1.125rem; margin: 0.125rem 0 0; padding: 0; accent-color: var(--accent); } |
| 265 | 265 | #sync-hint { margin-top: 0.75rem; } |
| 266 | +#language-hint, #language-unsupported { margin-top: 0.75rem; } | |
| 266 | 267 | |
| 267 | 268 | /* The sticky save tray stays in document flow, so the final section can |
| 268 | 269 | * scroll completely above it. Safe-area padding also covers home indicators. */ |
| @@ -263,6 +263,7 @@ button:disabled { cursor: default; opacity: 0.35; } | |||
| 263 | .check-row { display: flex; align-items: flex-start; gap: 0.75rem; min-height: 2.75rem; padding: 0.75rem; border-radius: 0.75rem; background: var(--page); color: var(--heading); font-size: 0.875rem; cursor: pointer; } | 263 | .check-row { display: flex; align-items: flex-start; gap: 0.75rem; min-height: 2.75rem; padding: 0.75rem; border-radius: 0.75rem; background: var(--page); color: var(--heading); font-size: 0.875rem; cursor: pointer; } |
| 264 | .check-row input { flex: 0 0 1.125rem; width: 1.125rem; min-height: 0; height: 1.125rem; margin: 0.125rem 0 0; padding: 0; accent-color: var(--accent); } | 264 | .check-row input { flex: 0 0 1.125rem; width: 1.125rem; min-height: 0; height: 1.125rem; margin: 0.125rem 0 0; padding: 0; accent-color: var(--accent); } |
| 265 | #sync-hint { margin-top: 0.75rem; } | 265 | #sync-hint { margin-top: 0.75rem; } |
| 266 | +#language-hint, #language-unsupported { margin-top: 0.75rem; } | ||
| 266 | 267 | ||
| 267 | /* The sticky save tray stays in document flow, so the final section can | 268 | /* The sticky save tray stays in document flow, so the final section can |
| 268 | * scroll completely above it. Safe-area padding also covers home indicators. */ | 269 | * scroll completely above it. Safe-area padding also covers home indicators. */ |
modified
packages/config-page/test/config-page.test.ts +173 -16 | @@ -132,7 +132,15 @@ function legacyFavorite(id: string, sortOrder: number): LegacyFavorite { | ||
| 132 | 132 | return withoutArrival; |
| 133 | 133 | } |
| 134 | 134 | |
| 135 | -const EMPTY_READONLY_STATE = { hasKey: false, language: "en", locale: "en", favorites: [], editable: false }; | |
| 135 | +const EMPTY_READONLY_STATE = { | |
| 136 | + hasKey: false, | |
| 137 | + language: "en", | |
| 138 | + locale: "en", | |
| 139 | + favorites: [], | |
| 140 | + editable: false, | |
| 141 | + languagePreference: "auto", | |
| 142 | + languagePreferenceSupported: false, | |
| 143 | +}; | |
| 136 | 144 | |
| 137 | 145 | function openingFragment(value: Record<string, unknown>): string { |
| 138 | 146 | return `#${encodeURIComponent(JSON.stringify(value))}`; |
| @@ -154,6 +162,20 @@ function legacyFragmentWith(favorites: unknown[], overrides: Record<string, unkn | ||
| 154 | 162 | return openingFragment({ hasKey: true, favorites, language: "fr_FR", ...overrides }); |
| 155 | 163 | } |
| 156 | 164 | |
| 165 | +// A current phone-local record: CONFIG_SCHEMA_VERSION 3 with a required | |
| 166 | +// languagePreference. Older stored shapes only exist through the companion's | |
| 167 | +// migration and are never produced here. | |
| 168 | +function storedConfiguration(overrides: Record<string, unknown> = {}): Record<string, unknown> { | |
| 169 | + return { | |
| 170 | + schemaVersion: companionConfiguration.CONFIG_SCHEMA_VERSION, | |
| 171 | + favorites: fixtureList, | |
| 172 | + primApiKey: "stored-personal-key", | |
| 173 | + keyStatus: 1, | |
| 174 | + languagePreference: "auto", | |
| 175 | + ...overrides, | |
| 176 | + }; | |
| 177 | +} | |
| 178 | + | |
| 157 | 179 | test("mirrored constants equal the canonical contract", () => { |
| 158 | 180 | assert.equal(SCHEMA_VERSION, CANONICAL_SCHEMA_VERSION); |
| 159 | 181 | assert.equal(SCHEMA_VERSION, 1); |
| @@ -332,7 +354,7 @@ test("the config page consumes the companion-produced opening fragment exactly", | ||
| 332 | 354 | const key = "stored-personal-key"; |
| 333 | 355 | const url = companionConfiguration.configurationUrl( |
| 334 | 356 | "https://config.example.test/index.html", |
| 335 | - { schemaVersion: CONFIGURATION_VERSION, favorites: fixtureList, primApiKey: key, keyStatus: 1 }, | |
| 357 | + storedConfiguration({ favorites: fixtureList, primApiKey: key }), | |
| 336 | 358 | "fr_FR", |
| 337 | 359 | ); |
| 338 | 360 | assert.equal(typeof url, "string"); |
| @@ -340,11 +362,15 @@ test("the config page consumes the companion-produced opening fragment exactly", | ||
| 340 | 362 | assert.equal(url.includes(key), false); |
| 341 | 363 | |
| 342 | 364 | const openingState = JSON.parse(decodeURIComponent(url.slice(url.indexOf("#") + 1))); |
| 343 | - assert.deepEqual(Object.keys(openingState), ["schemaVersion", "hasKey", "favorites", "language"]); | |
| 365 | + assert.deepEqual( | |
| 366 | + Object.keys(openingState), | |
| 367 | + ["schemaVersion", "hasKey", "favorites", "languagePreference", "language"], | |
| 368 | + ); | |
| 344 | 369 | assert.deepEqual(openingState, { |
| 345 | 370 | schemaVersion: CONFIGURATION_VERSION, |
| 346 | 371 | hasKey: true, |
| 347 | 372 | favorites: fixtureList, |
| 373 | + languagePreference: "auto", | |
| 348 | 374 | language: "fr_FR", |
| 349 | 375 | }); |
| 350 | 376 | assert.deepEqual(parseConfigFragment(new URL(url).hash), { |
| @@ -353,9 +379,151 @@ test("the config page consumes the companion-produced opening fragment exactly", | ||
| 353 | 379 | locale: "fr", |
| 354 | 380 | favorites: fixtureList, |
| 355 | 381 | editable: true, |
| 382 | + languagePreference: "auto", | |
| 383 | + languagePreferenceSupported: true, | |
| 356 | 384 | }); |
| 357 | 385 | }); |
| 358 | 386 | |
| 387 | +test("an advertised languagePreference enables a strict selector round trip", () => { | |
| 388 | + const parsed = parseConfigFragment(fragmentWith(fixtureList, { languagePreference: "fr" })); | |
| 389 | + assert.equal(parsed.editable, true); | |
| 390 | + assert.equal(parsed.languagePreference, "fr"); | |
| 391 | + assert.equal(parsed.languagePreferenceSupported, true); | |
| 392 | + | |
| 393 | + const state = initialConfigState(parsed); | |
| 394 | + assert.equal(state.languagePreference, "fr"); | |
| 395 | + assert.equal(state.languagePreferenceSupported, true); | |
| 396 | + | |
| 397 | + // An untouched selector replays the launch value verbatim. | |
| 398 | + const untouched = planConfigResult(state); | |
| 399 | + assert.equal(untouched.ok, true); | |
| 400 | + if (!untouched.ok) return; | |
| 401 | + assert.equal(untouched.payload.languagePreference, "fr"); | |
| 402 | + assert.equal(closePayloadFits(untouched.payload), true); | |
| 403 | + | |
| 404 | + // Every supported value is accepted exactly, including an explicit "auto" | |
| 405 | + // that clears the override, and nothing outside the trio is ever adopted. | |
| 406 | + for (const value of ["auto", "en", "fr"]) { | |
| 407 | + assert.equal(reduceConfigState(state, { type: "language-preference", value }).languagePreference, value); | |
| 408 | + } | |
| 409 | + assert.equal(reduceConfigState(state, { type: "language-preference", value: "de" }), state); | |
| 410 | + | |
| 411 | + // A preference-only save leaves favorites and the key decision untouched. | |
| 412 | + const changed = planConfigResult(reduceConfigState(state, { type: "language-preference", value: "en" })); | |
| 413 | + assert.equal(changed.ok, true); | |
| 414 | + if (!changed.ok) return; | |
| 415 | + assert.equal(changed.payload.languagePreference, "en"); | |
| 416 | + assert.deepEqual(changed.payload.favorites, untouched.payload.favorites); | |
| 417 | + assert.deepEqual(changed.payload.apiKeyUpdate, untouched.payload.apiKeyUpdate); | |
| 418 | + | |
| 419 | + // A read-only session shows the launch preference but can never save. | |
| 420 | + const readOnly = parseConfigFragment(fragmentWith( | |
| 421 | + [{ ...favorite("bad", 0), stopLabel: "x".repeat(97) }], | |
| 422 | + { languagePreference: "fr" }, | |
| 423 | + )); | |
| 424 | + assert.equal(readOnly.editable, false); | |
| 425 | + assert.equal(readOnly.languagePreference, "fr"); | |
| 426 | + assert.equal(readOnly.languagePreferenceSupported, true); | |
| 427 | + assert.equal(planConfigResult(initialConfigState(readOnly)).ok, false); | |
| 428 | +}); | |
| 429 | + | |
| 430 | +test("an unadvertised launch keeps the selector inert and the close payload free of the preference", () => { | |
| 431 | + // An older companion build emits the v2 envelope without the field. | |
| 432 | + const parsed = parseConfigFragment(openingFragment({ | |
| 433 | + schemaVersion: CONFIGURATION_VERSION, | |
| 434 | + hasKey: true, | |
| 435 | + favorites: fixtureList, | |
| 436 | + language: "fr_FR", | |
| 437 | + })); | |
| 438 | + assert.equal(parsed.editable, true); | |
| 439 | + assert.equal(parsed.languagePreference, "auto"); | |
| 440 | + assert.equal(parsed.languagePreferenceSupported, false); | |
| 441 | + | |
| 442 | + const state = initialConfigState(parsed); | |
| 443 | + // Even a supported value is ignored: the session never adopts a preference | |
| 444 | + // the launch did not advertise. | |
| 445 | + const changed = reduceConfigState(state, { type: "language-preference", value: "en" }); | |
| 446 | + assert.equal(changed, state); | |
| 447 | + assert.equal(changed.languagePreference, "auto"); | |
| 448 | + | |
| 449 | + const outcome = planConfigResult(state); | |
| 450 | + assert.equal(outcome.ok, true); | |
| 451 | + if (!outcome.ok) return; | |
| 452 | + // Omission is the compatibility contract: the phone preserves the stored | |
| 453 | + // preference instead of receiving an unknown field it must reject whole. | |
| 454 | + assert.deepEqual(Object.keys(outcome.payload).sort(), ["apiKeyUpdate", "favorites", "schemaVersion"]); | |
| 455 | + const parsedUpdate = companionConfiguration.parseCloseFragment(encodeCloseFragment(outcome.payload)); | |
| 456 | + assert.notEqual(parsedUpdate, null); | |
| 457 | + if (parsedUpdate === null) return; | |
| 458 | + assert.equal(Object.hasOwn(parsedUpdate, "languagePreference"), false); | |
| 459 | + | |
| 460 | + // The same omission leaves an existing explicit override untouched. | |
| 461 | + const applied = companionConfiguration.applyConfigurationUpdate( | |
| 462 | + storedConfiguration({ languagePreference: "en" }), | |
| 463 | + parsedUpdate, | |
| 464 | + ); | |
| 465 | + assert.notEqual(applied, null); | |
| 466 | + if (applied === null) return; | |
| 467 | + assert.equal(applied.languagePreference, "en"); | |
| 468 | + | |
| 469 | + // Legacy and unversioned envelopes are read-only and never advertise. | |
| 470 | + assert.equal(parseConfigFragment(legacyFragmentWith(fixtureList)).languagePreferenceSupported, false); | |
| 471 | +}); | |
| 472 | + | |
| 473 | +test("an invalid present languagePreference rejects the whole opening without coercion", () => { | |
| 474 | + for (const bad of ["de", "DE", "", "auto ", "fr-FR", 1, null, true]) { | |
| 475 | + const parsed = parseConfigFragment(fragmentWith(fixtureList, { languagePreference: bad })); | |
| 476 | + assert.deepEqual(parsed, EMPTY_READONLY_STATE); | |
| 477 | + const outcome = planConfigResult(initialConfigState(parsed)); | |
| 478 | + assert.equal(outcome.ok, false); | |
| 479 | + if (outcome.ok) return; | |
| 480 | + assert.equal(typeof COPY.en[outcome.error], "string"); | |
| 481 | + } | |
| 482 | +}); | |
| 483 | + | |
| 484 | +test("explicit choices stay distinct from automatic across a full save and reopen", () => { | |
| 485 | + // A saved explicit French choice matching the system language must never | |
| 486 | + // come back as Automatic. | |
| 487 | + const stored = storedConfiguration({ languagePreference: "fr" }); | |
| 488 | + const opened = parseConfigFragment(new URL( | |
| 489 | + companionConfiguration.configurationUrl("https://config.example.test/index.html", stored, "fr_FR") ?? "", | |
| 490 | + ).hash); | |
| 491 | + assert.equal(opened.languagePreference, "fr"); | |
| 492 | + assert.equal(opened.languagePreferenceSupported, true); | |
| 493 | + assert.equal(opened.locale, "fr"); | |
| 494 | + | |
| 495 | + // The user returns the selector to Automatic and saves. | |
| 496 | + const state = reduceConfigState(initialConfigState(opened), { type: "language-preference", value: "auto" }); | |
| 497 | + const outcome = planConfigResult(state); | |
| 498 | + assert.equal(outcome.ok, true); | |
| 499 | + if (!outcome.ok) return; | |
| 500 | + assert.equal(outcome.payload.languagePreference, "auto"); | |
| 501 | + | |
| 502 | + const parsedUpdate = companionConfiguration.parseCloseFragment(encodeCloseFragment(outcome.payload)); | |
| 503 | + assert.notEqual(parsedUpdate, null); | |
| 504 | + if (parsedUpdate === null) return; | |
| 505 | + assert.equal(parsedUpdate.languagePreference, "auto"); | |
| 506 | + | |
| 507 | + // The commit keeps favorites and the key, and stores the explicit reset. | |
| 508 | + const applied = companionConfiguration.applyConfigurationUpdate(stored, parsedUpdate); | |
| 509 | + assert.notEqual(applied, null); | |
| 510 | + if (applied === null) return; | |
| 511 | + assert.equal(applied.languagePreference, "auto"); | |
| 512 | + assert.deepEqual(applied.favorites, stored.favorites); | |
| 513 | + assert.equal(applied.primApiKey, stored.primApiKey); | |
| 514 | + assert.equal(applied.keyStatus, stored.keyStatus); | |
| 515 | + assert.equal(companionConfiguration.isStoredConfiguration(applied), true); | |
| 516 | + | |
| 517 | + // Reopening restores Automatic as the stored intent while the resolved | |
| 518 | + // system language still drives the page copy. | |
| 519 | + const reopened = parseConfigFragment(new URL( | |
| 520 | + companionConfiguration.configurationUrl("https://config.example.test/index.html", applied, "fr_FR") ?? "", | |
| 521 | + ).hash); | |
| 522 | + assert.equal(reopened.languagePreference, "auto"); | |
| 523 | + assert.equal(reopened.languagePreferenceSupported, true); | |
| 524 | + assert.equal(reopened.locale, "fr"); | |
| 525 | +}); | |
| 526 | + | |
| 359 | 527 | |
| 360 | 528 | test("a planted key field never enters page state or the payload", () => { |
| 361 | 529 | const parsed = parseConfigFragment(fragmentWith(fixtureList, { apiKey: "SECRET-VALUE" })); |
| @@ -846,12 +1014,7 @@ test("routing and arrivals survive the full page round trip and phone-side valid | ||
| 846 | 1014 | |
| 847 | 1015 | // The companion-produced opening fragment carries routing and arrivals and |
| 848 | 1016 | // never the key. |
| 849 | - const stored = { | |
| 850 | - schemaVersion: CONFIGURATION_VERSION, | |
| 851 | - favorites: outcome.payload.favorites, | |
| 852 | - primApiKey: "stored-personal-key", | |
| 853 | - keyStatus: 1, | |
| 854 | - }; | |
| 1017 | + const stored = storedConfiguration({ favorites: outcome.payload.favorites }); | |
| 855 | 1018 | const url = companionConfiguration.configurationUrl( |
| 856 | 1019 | "https://config.example.test/index.html", |
| 857 | 1020 | stored, |
| @@ -1052,12 +1215,7 @@ test("encoded close and opening fragments enforce the 32768 bound at producers", | ||
| 1052 | 1215 | assert.equal(companionConfiguration.parseCloseFragment(encodeCloseFragment(bloated)), null); |
| 1053 | 1216 | |
| 1054 | 1217 | // The opening producer never emits a fragment the page would discard. |
| 1055 | - const oversizedConfig = { | |
| 1056 | - schemaVersion: CONFIGURATION_VERSION, | |
| 1057 | - favorites: [bloatedFavorite], | |
| 1058 | - primApiKey: "stored-personal-key", | |
| 1059 | - keyStatus: 1, | |
| 1060 | - }; | |
| 1218 | + const oversizedConfig = storedConfiguration({ favorites: [bloatedFavorite] }); | |
| 1061 | 1219 | assert.equal(companionConfiguration.isStoredConfiguration(oversizedConfig), true); |
| 1062 | 1220 | assert.equal( |
| 1063 | 1221 | companionConfiguration.configurationUrl("https://config.example.test/index.html", oversizedConfig, "en_US"), |
| @@ -1116,7 +1274,6 @@ test("page keeps secrets out of durable and observable surfaces", () => { | ||
| 1116 | 1274 | assert.doesNotMatch(client, /prim\.iledefrance-mobilites/u); |
| 1117 | 1275 | assert.match(html, /type="password" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"/u); |
| 1118 | 1276 | assert.match(html, /connect-src 'self'/u); |
| 1119 | - assert.doesNotMatch(html, /value=["'][^"']+["']/u); | |
| 1120 | 1277 | assert.match(html, /img-src 'self'/u); |
| 1121 | 1278 | assert.doesNotMatch(html, /img-src 'none'/u); |
| 1122 | 1279 | }); |
| @@ -132,7 +132,15 @@ function legacyFavorite(id: string, sortOrder: number): LegacyFavorite { | |||
| 132 | return withoutArrival; | 132 | return withoutArrival; |
| 133 | } | 133 | } |
| 134 | 134 | ||
| 135 | -const EMPTY_READONLY_STATE = { hasKey: false, language: "en", locale: "en", favorites: [], editable: false }; | 135 | +const EMPTY_READONLY_STATE = { |
| 136 | + hasKey: false, | ||
| 137 | + language: "en", | ||
| 138 | + locale: "en", | ||
| 139 | + favorites: [], | ||
| 140 | + editable: false, | ||
| 141 | + languagePreference: "auto", | ||
| 142 | + languagePreferenceSupported: false, | ||
| 143 | +}; | ||
| 136 | 144 | ||
| 137 | function openingFragment(value: Record<string, unknown>): string { | 145 | function openingFragment(value: Record<string, unknown>): string { |
| 138 | return `#${encodeURIComponent(JSON.stringify(value))}`; | 146 | return `#${encodeURIComponent(JSON.stringify(value))}`; |
| @@ -154,6 +162,20 @@ function legacyFragmentWith(favorites: unknown[], overrides: Record<string, unkn | |||
| 154 | return openingFragment({ hasKey: true, favorites, language: "fr_FR", ...overrides }); | 162 | return openingFragment({ hasKey: true, favorites, language: "fr_FR", ...overrides }); |
| 155 | } | 163 | } |
| 156 | 164 | ||
| 165 | +// A current phone-local record: CONFIG_SCHEMA_VERSION 3 with a required | ||
| 166 | +// languagePreference. Older stored shapes only exist through the companion's | ||
| 167 | +// migration and are never produced here. | ||
| 168 | +function storedConfiguration(overrides: Record<string, unknown> = {}): Record<string, unknown> { | ||
| 169 | + return { | ||
| 170 | + schemaVersion: companionConfiguration.CONFIG_SCHEMA_VERSION, | ||
| 171 | + favorites: fixtureList, | ||
| 172 | + primApiKey: "stored-personal-key", | ||
| 173 | + keyStatus: 1, | ||
| 174 | + languagePreference: "auto", | ||
| 175 | + ...overrides, | ||
| 176 | + }; | ||
| 177 | +} | ||
| 178 | + | ||
| 157 | test("mirrored constants equal the canonical contract", () => { | 179 | test("mirrored constants equal the canonical contract", () => { |
| 158 | assert.equal(SCHEMA_VERSION, CANONICAL_SCHEMA_VERSION); | 180 | assert.equal(SCHEMA_VERSION, CANONICAL_SCHEMA_VERSION); |
| 159 | assert.equal(SCHEMA_VERSION, 1); | 181 | assert.equal(SCHEMA_VERSION, 1); |
| @@ -332,7 +354,7 @@ test("the config page consumes the companion-produced opening fragment exactly", | |||
| 332 | const key = "stored-personal-key"; | 354 | const key = "stored-personal-key"; |
| 333 | const url = companionConfiguration.configurationUrl( | 355 | const url = companionConfiguration.configurationUrl( |
| 334 | "https://config.example.test/index.html", | 356 | "https://config.example.test/index.html", |
| 335 | - { schemaVersion: CONFIGURATION_VERSION, favorites: fixtureList, primApiKey: key, keyStatus: 1 }, | 357 | + storedConfiguration({ favorites: fixtureList, primApiKey: key }), |
| 336 | "fr_FR", | 358 | "fr_FR", |
| 337 | ); | 359 | ); |
| 338 | assert.equal(typeof url, "string"); | 360 | assert.equal(typeof url, "string"); |
| @@ -340,11 +362,15 @@ test("the config page consumes the companion-produced opening fragment exactly", | |||
| 340 | assert.equal(url.includes(key), false); | 362 | assert.equal(url.includes(key), false); |
| 341 | 363 | ||
| 342 | const openingState = JSON.parse(decodeURIComponent(url.slice(url.indexOf("#") + 1))); | 364 | const openingState = JSON.parse(decodeURIComponent(url.slice(url.indexOf("#") + 1))); |
| 343 | - assert.deepEqual(Object.keys(openingState), ["schemaVersion", "hasKey", "favorites", "language"]); | 365 | + assert.deepEqual( |
| 366 | + Object.keys(openingState), | ||
| 367 | + ["schemaVersion", "hasKey", "favorites", "languagePreference", "language"], | ||
| 368 | + ); | ||
| 344 | assert.deepEqual(openingState, { | 369 | assert.deepEqual(openingState, { |
| 345 | schemaVersion: CONFIGURATION_VERSION, | 370 | schemaVersion: CONFIGURATION_VERSION, |
| 346 | hasKey: true, | 371 | hasKey: true, |
| 347 | favorites: fixtureList, | 372 | favorites: fixtureList, |
| 373 | + languagePreference: "auto", | ||
| 348 | language: "fr_FR", | 374 | language: "fr_FR", |
| 349 | }); | 375 | }); |
| 350 | assert.deepEqual(parseConfigFragment(new URL(url).hash), { | 376 | assert.deepEqual(parseConfigFragment(new URL(url).hash), { |
| @@ -353,9 +379,151 @@ test("the config page consumes the companion-produced opening fragment exactly", | |||
| 353 | locale: "fr", | 379 | locale: "fr", |
| 354 | favorites: fixtureList, | 380 | favorites: fixtureList, |
| 355 | editable: true, | 381 | editable: true, |
| 382 | + languagePreference: "auto", | ||
| 383 | + languagePreferenceSupported: true, | ||
| 356 | }); | 384 | }); |
| 357 | }); | 385 | }); |
| 358 | 386 | ||
| 387 | +test("an advertised languagePreference enables a strict selector round trip", () => { | ||
| 388 | + const parsed = parseConfigFragment(fragmentWith(fixtureList, { languagePreference: "fr" })); | ||
| 389 | + assert.equal(parsed.editable, true); | ||
| 390 | + assert.equal(parsed.languagePreference, "fr"); | ||
| 391 | + assert.equal(parsed.languagePreferenceSupported, true); | ||
| 392 | + | ||
| 393 | + const state = initialConfigState(parsed); | ||
| 394 | + assert.equal(state.languagePreference, "fr"); | ||
| 395 | + assert.equal(state.languagePreferenceSupported, true); | ||
| 396 | + | ||
| 397 | + // An untouched selector replays the launch value verbatim. | ||
| 398 | + const untouched = planConfigResult(state); | ||
| 399 | + assert.equal(untouched.ok, true); | ||
| 400 | + if (!untouched.ok) return; | ||
| 401 | + assert.equal(untouched.payload.languagePreference, "fr"); | ||
| 402 | + assert.equal(closePayloadFits(untouched.payload), true); | ||
| 403 | + | ||
| 404 | + // Every supported value is accepted exactly, including an explicit "auto" | ||
| 405 | + // that clears the override, and nothing outside the trio is ever adopted. | ||
| 406 | + for (const value of ["auto", "en", "fr"]) { | ||
| 407 | + assert.equal(reduceConfigState(state, { type: "language-preference", value }).languagePreference, value); | ||
| 408 | + } | ||
| 409 | + assert.equal(reduceConfigState(state, { type: "language-preference", value: "de" }), state); | ||
| 410 | + | ||
| 411 | + // A preference-only save leaves favorites and the key decision untouched. | ||
| 412 | + const changed = planConfigResult(reduceConfigState(state, { type: "language-preference", value: "en" })); | ||
| 413 | + assert.equal(changed.ok, true); | ||
| 414 | + if (!changed.ok) return; | ||
| 415 | + assert.equal(changed.payload.languagePreference, "en"); | ||
| 416 | + assert.deepEqual(changed.payload.favorites, untouched.payload.favorites); | ||
| 417 | + assert.deepEqual(changed.payload.apiKeyUpdate, untouched.payload.apiKeyUpdate); | ||
| 418 | + | ||
| 419 | + // A read-only session shows the launch preference but can never save. | ||
| 420 | + const readOnly = parseConfigFragment(fragmentWith( | ||
| 421 | + [{ ...favorite("bad", 0), stopLabel: "x".repeat(97) }], | ||
| 422 | + { languagePreference: "fr" }, | ||
| 423 | + )); | ||
| 424 | + assert.equal(readOnly.editable, false); | ||
| 425 | + assert.equal(readOnly.languagePreference, "fr"); | ||
| 426 | + assert.equal(readOnly.languagePreferenceSupported, true); | ||
| 427 | + assert.equal(planConfigResult(initialConfigState(readOnly)).ok, false); | ||
| 428 | +}); | ||
| 429 | + | ||
| 430 | +test("an unadvertised launch keeps the selector inert and the close payload free of the preference", () => { | ||
| 431 | + // An older companion build emits the v2 envelope without the field. | ||
| 432 | + const parsed = parseConfigFragment(openingFragment({ | ||
| 433 | + schemaVersion: CONFIGURATION_VERSION, | ||
| 434 | + hasKey: true, | ||
| 435 | + favorites: fixtureList, | ||
| 436 | + language: "fr_FR", | ||
| 437 | + })); | ||
| 438 | + assert.equal(parsed.editable, true); | ||
| 439 | + assert.equal(parsed.languagePreference, "auto"); | ||
| 440 | + assert.equal(parsed.languagePreferenceSupported, false); | ||
| 441 | + | ||
| 442 | + const state = initialConfigState(parsed); | ||
| 443 | + // Even a supported value is ignored: the session never adopts a preference | ||
| 444 | + // the launch did not advertise. | ||
| 445 | + const changed = reduceConfigState(state, { type: "language-preference", value: "en" }); | ||
| 446 | + assert.equal(changed, state); | ||
| 447 | + assert.equal(changed.languagePreference, "auto"); | ||
| 448 | + | ||
| 449 | + const outcome = planConfigResult(state); | ||
| 450 | + assert.equal(outcome.ok, true); | ||
| 451 | + if (!outcome.ok) return; | ||
| 452 | + // Omission is the compatibility contract: the phone preserves the stored | ||
| 453 | + // preference instead of receiving an unknown field it must reject whole. | ||
| 454 | + assert.deepEqual(Object.keys(outcome.payload).sort(), ["apiKeyUpdate", "favorites", "schemaVersion"]); | ||
| 455 | + const parsedUpdate = companionConfiguration.parseCloseFragment(encodeCloseFragment(outcome.payload)); | ||
| 456 | + assert.notEqual(parsedUpdate, null); | ||
| 457 | + if (parsedUpdate === null) return; | ||
| 458 | + assert.equal(Object.hasOwn(parsedUpdate, "languagePreference"), false); | ||
| 459 | + | ||
| 460 | + // The same omission leaves an existing explicit override untouched. | ||
| 461 | + const applied = companionConfiguration.applyConfigurationUpdate( | ||
| 462 | + storedConfiguration({ languagePreference: "en" }), | ||
| 463 | + parsedUpdate, | ||
| 464 | + ); | ||
| 465 | + assert.notEqual(applied, null); | ||
| 466 | + if (applied === null) return; | ||
| 467 | + assert.equal(applied.languagePreference, "en"); | ||
| 468 | + | ||
| 469 | + // Legacy and unversioned envelopes are read-only and never advertise. | ||
| 470 | + assert.equal(parseConfigFragment(legacyFragmentWith(fixtureList)).languagePreferenceSupported, false); | ||
| 471 | +}); | ||
| 472 | + | ||
| 473 | +test("an invalid present languagePreference rejects the whole opening without coercion", () => { | ||
| 474 | + for (const bad of ["de", "DE", "", "auto ", "fr-FR", 1, null, true]) { | ||
| 475 | + const parsed = parseConfigFragment(fragmentWith(fixtureList, { languagePreference: bad })); | ||
| 476 | + assert.deepEqual(parsed, EMPTY_READONLY_STATE); | ||
| 477 | + const outcome = planConfigResult(initialConfigState(parsed)); | ||
| 478 | + assert.equal(outcome.ok, false); | ||
| 479 | + if (outcome.ok) return; | ||
| 480 | + assert.equal(typeof COPY.en[outcome.error], "string"); | ||
| 481 | + } | ||
| 482 | +}); | ||
| 483 | + | ||
| 484 | +test("explicit choices stay distinct from automatic across a full save and reopen", () => { | ||
| 485 | + // A saved explicit French choice matching the system language must never | ||
| 486 | + // come back as Automatic. | ||
| 487 | + const stored = storedConfiguration({ languagePreference: "fr" }); | ||
| 488 | + const opened = parseConfigFragment(new URL( | ||
| 489 | + companionConfiguration.configurationUrl("https://config.example.test/index.html", stored, "fr_FR") ?? "", | ||
| 490 | + ).hash); | ||
| 491 | + assert.equal(opened.languagePreference, "fr"); | ||
| 492 | + assert.equal(opened.languagePreferenceSupported, true); | ||
| 493 | + assert.equal(opened.locale, "fr"); | ||
| 494 | + | ||
| 495 | + // The user returns the selector to Automatic and saves. | ||
| 496 | + const state = reduceConfigState(initialConfigState(opened), { type: "language-preference", value: "auto" }); | ||
| 497 | + const outcome = planConfigResult(state); | ||
| 498 | + assert.equal(outcome.ok, true); | ||
| 499 | + if (!outcome.ok) return; | ||
| 500 | + assert.equal(outcome.payload.languagePreference, "auto"); | ||
| 501 | + | ||
| 502 | + const parsedUpdate = companionConfiguration.parseCloseFragment(encodeCloseFragment(outcome.payload)); | ||
| 503 | + assert.notEqual(parsedUpdate, null); | ||
| 504 | + if (parsedUpdate === null) return; | ||
| 505 | + assert.equal(parsedUpdate.languagePreference, "auto"); | ||
| 506 | + | ||
| 507 | + // The commit keeps favorites and the key, and stores the explicit reset. | ||
| 508 | + const applied = companionConfiguration.applyConfigurationUpdate(stored, parsedUpdate); | ||
| 509 | + assert.notEqual(applied, null); | ||
| 510 | + if (applied === null) return; | ||
| 511 | + assert.equal(applied.languagePreference, "auto"); | ||
| 512 | + assert.deepEqual(applied.favorites, stored.favorites); | ||
| 513 | + assert.equal(applied.primApiKey, stored.primApiKey); | ||
| 514 | + assert.equal(applied.keyStatus, stored.keyStatus); | ||
| 515 | + assert.equal(companionConfiguration.isStoredConfiguration(applied), true); | ||
| 516 | + | ||
| 517 | + // Reopening restores Automatic as the stored intent while the resolved | ||
| 518 | + // system language still drives the page copy. | ||
| 519 | + const reopened = parseConfigFragment(new URL( | ||
| 520 | + companionConfiguration.configurationUrl("https://config.example.test/index.html", applied, "fr_FR") ?? "", | ||
| 521 | + ).hash); | ||
| 522 | + assert.equal(reopened.languagePreference, "auto"); | ||
| 523 | + assert.equal(reopened.languagePreferenceSupported, true); | ||
| 524 | + assert.equal(reopened.locale, "fr"); | ||
| 525 | +}); | ||
| 526 | + | ||
| 359 | 527 | ||
| 360 | test("a planted key field never enters page state or the payload", () => { | 528 | test("a planted key field never enters page state or the payload", () => { |
| 361 | const parsed = parseConfigFragment(fragmentWith(fixtureList, { apiKey: "SECRET-VALUE" })); | 529 | const parsed = parseConfigFragment(fragmentWith(fixtureList, { apiKey: "SECRET-VALUE" })); |
| @@ -846,12 +1014,7 @@ test("routing and arrivals survive the full page round trip and phone-side valid | |||
| 846 | 1014 | ||
| 847 | // The companion-produced opening fragment carries routing and arrivals and | 1015 | // The companion-produced opening fragment carries routing and arrivals and |
| 848 | // never the key. | 1016 | // never the key. |
| 849 | - const stored = { | 1017 | + const stored = storedConfiguration({ favorites: outcome.payload.favorites }); |
| 850 | - schemaVersion: CONFIGURATION_VERSION, | ||
| 851 | - favorites: outcome.payload.favorites, | ||
| 852 | - primApiKey: "stored-personal-key", | ||
| 853 | - keyStatus: 1, | ||
| 854 | - }; | ||
| 855 | const url = companionConfiguration.configurationUrl( | 1018 | const url = companionConfiguration.configurationUrl( |
| 856 | "https://config.example.test/index.html", | 1019 | "https://config.example.test/index.html", |
| 857 | stored, | 1020 | stored, |
| @@ -1052,12 +1215,7 @@ test("encoded close and opening fragments enforce the 32768 bound at producers", | |||
| 1052 | assert.equal(companionConfiguration.parseCloseFragment(encodeCloseFragment(bloated)), null); | 1215 | assert.equal(companionConfiguration.parseCloseFragment(encodeCloseFragment(bloated)), null); |
| 1053 | 1216 | ||
| 1054 | // The opening producer never emits a fragment the page would discard. | 1217 | // The opening producer never emits a fragment the page would discard. |
| 1055 | - const oversizedConfig = { | 1218 | + const oversizedConfig = storedConfiguration({ favorites: [bloatedFavorite] }); |
| 1056 | - schemaVersion: CONFIGURATION_VERSION, | ||
| 1057 | - favorites: [bloatedFavorite], | ||
| 1058 | - primApiKey: "stored-personal-key", | ||
| 1059 | - keyStatus: 1, | ||
| 1060 | - }; | ||
| 1061 | assert.equal(companionConfiguration.isStoredConfiguration(oversizedConfig), true); | 1219 | assert.equal(companionConfiguration.isStoredConfiguration(oversizedConfig), true); |
| 1062 | assert.equal( | 1220 | assert.equal( |
| 1063 | companionConfiguration.configurationUrl("https://config.example.test/index.html", oversizedConfig, "en_US"), | 1221 | companionConfiguration.configurationUrl("https://config.example.test/index.html", oversizedConfig, "en_US"), |
| @@ -1116,7 +1274,6 @@ test("page keeps secrets out of durable and observable surfaces", () => { | |||
| 1116 | assert.doesNotMatch(client, /prim\.iledefrance-mobilites/u); | 1274 | assert.doesNotMatch(client, /prim\.iledefrance-mobilites/u); |
| 1117 | assert.match(html, /type="password" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"/u); | 1275 | assert.match(html, /type="password" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"/u); |
| 1118 | assert.match(html, /connect-src 'self'/u); | 1276 | assert.match(html, /connect-src 'self'/u); |
| 1119 | - assert.doesNotMatch(html, /value=["'][^"']+["']/u); | ||
| 1120 | assert.match(html, /img-src 'self'/u); | 1277 | assert.match(html, /img-src 'self'/u); |
| 1121 | assert.doesNotMatch(html, /img-src 'none'/u); | 1278 | assert.doesNotMatch(html, /img-src 'none'/u); |
| 1122 | }); | 1279 | }); |
modified
packages/contracts/src/index.ts +2 -2 | @@ -1,6 +1,6 @@ | ||
| 1 | 1 | export const SCHEMA_VERSION = 1 as const; |
| 2 | -// CONFIGURATION_VERSION 2 covers only the stored configuration record and the | |
| 3 | -// page open/close envelopes. Domain objects, nested favorites and ApiKeyUpdate | |
| 2 | +// CONFIGURATION_VERSION 2 covers the page open/close envelopes. The companion | |
| 3 | +// versions its phone-local stored record separately. Domain objects and ApiKeyUpdate | |
| 4 | 4 | // keep SCHEMA_VERSION 1. Versioning the envelope also protects an empty |
| 5 | 5 | // backup: a phone that cannot read v2 must never blank the configuration with |
| 6 | 6 | // a v1 close. |
| @@ -1,6 +1,6 @@ | |||
| 1 | export const SCHEMA_VERSION = 1 as const; | 1 | export const SCHEMA_VERSION = 1 as const; |
| 2 | -// CONFIGURATION_VERSION 2 covers only the stored configuration record and the | 2 | +// CONFIGURATION_VERSION 2 covers the page open/close envelopes. The companion |
| 3 | -// page open/close envelopes. Domain objects, nested favorites and ApiKeyUpdate | 3 | +// versions its phone-local stored record separately. Domain objects and ApiKeyUpdate |
| 4 | // keep SCHEMA_VERSION 1. Versioning the envelope also protects an empty | 4 | // keep SCHEMA_VERSION 1. Versioning the envelope also protects an empty |
| 5 | // backup: a phone that cannot read v2 must never blank the configuration with | 5 | // backup: a phone that cannot read v2 must never blank the configuration with |
| 6 | // a v1 close. | 6 | // a v1 close. |
modified
packages/watch/test/fixture-boot.test.js +2 -1 | @@ -39,7 +39,8 @@ function coupled(t, { count = 2, age = 0, cached = count, profile = 0 } = {}) { | ||
| 39 | 39 | routing: { ...ROUTING } |
| 40 | 40 | })); |
| 41 | 41 | assert(configuration.saveConfiguration(storage, { |
| 42 | - schemaVersion: 2, favorites, keyStatus: contracts.KEY_STATUS.CONFIGURED, primApiKey: KEY | |
| 42 | + schemaVersion: configuration.CONFIG_SCHEMA_VERSION, languagePreference: "auto", | |
| 43 | + favorites, keyStatus: contracts.KEY_STATUS.CONFIGURED, primApiKey: KEY | |
| 43 | 44 | })); |
| 44 | 45 | let cache = configuration.emptyCache(); |
| 45 | 46 | if (cached) { |
| @@ -39,7 +39,8 @@ function coupled(t, { count = 2, age = 0, cached = count, profile = 0 } = {}) { | |||
| 39 | routing: { ...ROUTING } | 39 | routing: { ...ROUTING } |
| 40 | })); | 40 | })); |
| 41 | assert(configuration.saveConfiguration(storage, { | 41 | assert(configuration.saveConfiguration(storage, { |
| 42 | - schemaVersion: 2, favorites, keyStatus: contracts.KEY_STATUS.CONFIGURED, primApiKey: KEY | 42 | + schemaVersion: configuration.CONFIG_SCHEMA_VERSION, languagePreference: "auto", |
| 43 | + favorites, keyStatus: contracts.KEY_STATUS.CONFIGURED, primApiKey: KEY | ||
| 43 | })); | 44 | })); |
| 44 | let cache = configuration.emptyCache(); | 45 | let cache = configuration.emptyCache(); |
| 45 | if (cached) { | 46 | if (cached) { |