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

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

Download ZIP

feat(configuration): add persistent watch language selection (#30) (#31)Unverified

Cédric Anthony committed 2026-09-20T00:47:40+02:00 Browse files
1650944 parent: 7aaa70b
modified CHANGELOG.md +12 -0
@@ -1,5 +1,17 @@
11 # Changelog
22
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+
315 ## [1.1.0] - 2026-09-19
416
517 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 # Changelog1 # 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-1915 ## [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
4646 - **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.
4747 - **Refresh when needed.** Open the app, switch favorite, choose Refresh all, or hold Select to request arrivals. Recent results are reused for 60 seconds; after a failed refresh, the last complete result remains visibly stale for at most 15 minutes. There is no periodic network polling.
4848 - **Clear data states.** The app distinguishes real-time arrivals, mixed real-time and scheduled results, updates in progress, missing cached data, and trips whose route to your arrival is unconfirmed (`?`).
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.
5050 - **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.
5151
5252 ## 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 ## Compatibility52 ## Compatibility
modified packages/companion/src/configuration.js +44 -13
@@ -2,11 +2,13 @@
22
33 var contracts = require("./contracts");
44 var CONFIG_STORAGE_KEY = "lapinFuteConfig";
5+// Phone-local preference version; page envelopes remain CONFIGURATION_VERSION 2.
6+var CONFIG_SCHEMA_VERSION = 3;
57 var RESULTS_STORAGE_KEY = "lapinFuteResults";
68 // Phone-local cache version. Earlier caches did not bind results to an arrival.
79 var CACHE_SCHEMA_VERSION = 3;
810 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"];
1012 var CACHE_RECORD_KEYS = ["schemaVersion", "overview", "trafficDetails"];
1113 var OVERVIEW_ENTRY_KEYS = [
1214 "favoriteId",
@@ -24,7 +26,7 @@ var INVALID_KEY_STATUS_RECORD_KEYS = [
2426 "keyStatus",
2527 "configurationFingerprint"
2628 ];
27-var UPDATE_KEYS = ["schemaVersion", "favorites", "apiKeyUpdate", "forceFullSync"];
29+var UPDATE_KEYS = ["schemaVersion", "favorites", "apiKeyUpdate", "forceFullSync", "languagePreference"];
2830 var MAX_CLOSE_RESPONSE_LENGTH = 32768;
2931 var MAX_SAFE_INTEGER = 9007199254740991;
3032 var FAVORITE_STRING_KEYS = [
@@ -47,19 +49,21 @@ var ROUTING_STRING_KEYS = [
4749
4850 function emptyConfiguration() {
4951 return {
50- schemaVersion: contracts.CONFIGURATION_VERSION,
52+ schemaVersion: CONFIG_SCHEMA_VERSION,
5153 favorites: [],
5254 primApiKey: null,
53- keyStatus: contracts.KEY_STATUS.MISSING
55+ keyStatus: contracts.KEY_STATUS.MISSING,
56+ languagePreference: "auto"
5457 };
5558 }
5659
5760 function copyConfiguration(value) {
5861 return {
59- schemaVersion: contracts.CONFIGURATION_VERSION,
62+ schemaVersion: CONFIG_SCHEMA_VERSION,
6063 favorites: value.favorites.map(contracts.copyPhoneFavorite),
6164 primApiKey: value.primApiKey,
62- keyStatus: value.keyStatus
65+ keyStatus: value.keyStatus,
66+ languagePreference: value.languagePreference
6367 };
6468 }
6569
@@ -162,10 +166,20 @@ function activeWatchLanguage(Pebble) {
162166 }
163167 }
164168
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+
165177 function isStoredConfiguration(value) {
166178 if (!contracts.isObject(value)
167179 || !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)
169183 || !contracts.isPhoneFavoriteList(value.favorites)) return false;
170184 if (value.primApiKey === null) {
171185 return value.keyStatus === contracts.KEY_STATUS.MISSING;
@@ -179,8 +193,13 @@ function isStoredConfiguration(value) {
179193 function recoverStoredConfiguration(value) {
180194 if (!contracts.isObject(value) || !Array.isArray(value.favorites)
181195 || 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;
183198 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+ }
184203 // This freshly parsed record is private to restoration. Routing is replaceable
185204 // catalog enrichment; preserve every other field for ordinary validation.
186205 value.favorites.forEach(function (favorite) {
@@ -192,7 +211,7 @@ function recoverStoredConfiguration(value) {
192211 && Object.prototype.hasOwnProperty.call(favorite, "routing")
193212 && !contracts.isServiceRouting(favorite.routing)) delete favorite.routing;
194213 });
195- value.schemaVersion = contracts.CONFIGURATION_VERSION;
214+ value.schemaVersion = CONFIG_SCHEMA_VERSION;
196215 return isStoredConfiguration(value) ? value : null;
197216 }
198217
@@ -202,6 +221,8 @@ function isConfigurationUpdate(value) {
202221 && value.schemaVersion === contracts.CONFIGURATION_VERSION
203222 && contracts.isPhoneFavoriteList(value.favorites)
204223 && contracts.isApiKeyUpdate(value.apiKeyUpdate)
224+ && (!Object.prototype.hasOwnProperty.call(value, "languagePreference")
225+ || isLanguagePreference(value.languagePreference))
205226 && (!Object.prototype.hasOwnProperty.call(value, "forceFullSync")
206227 || typeof value.forceFullSync === "boolean");
207228 }
@@ -460,7 +481,7 @@ function loadConfiguration(storage) {
460481 var current = parseStored(storage, CONFIG_STORAGE_KEY);
461482 var invalidMarker = parseStored(storage, INVALID_KEY_STATUS_STORAGE_KEY).value;
462483 var loaded;
463- var migration = current.value && current.value.schemaVersion === contracts.SCHEMA_VERSION;
484+ var migration = current.value && current.value.schemaVersion !== CONFIG_SCHEMA_VERSION;
464485 if (!current.present) return emptyConfiguration();
465486 loaded = recoverStoredConfiguration(current.value);
466487 if (loaded === null) return null;
@@ -695,16 +716,19 @@ function applyConfigurationUpdate(current, update) {
695716 keyStatus = contracts.KEY_STATUS.MISSING;
696717 }
697718 return {
698- schemaVersion: contracts.CONFIGURATION_VERSION,
719+ schemaVersion: CONFIG_SCHEMA_VERSION,
699720 favorites: update.favorites.map(contracts.copyPhoneFavorite),
700721 primApiKey: primApiKey,
701- keyStatus: keyStatus
722+ keyStatus: keyStatus,
723+ languagePreference: Object.prototype.hasOwnProperty.call(update, "languagePreference")
724+ ? update.languagePreference : current.languagePreference
702725 };
703726 }
704727
705728 function parseCloseFragment(response) {
706729 var fragment;
707730 var parsed;
731+ var update;
708732 var closePrefix = "pebblejs://close#";
709733 if (typeof response !== "string"
710734 || response.length === 0
@@ -725,7 +749,7 @@ function parseCloseFragment(response) {
725749 }
726750 }
727751 if (!isConfigurationUpdate(parsed)) return null;
728- return {
752+ update = {
729753 schemaVersion: contracts.CONFIGURATION_VERSION,
730754 favorites: parsed.favorites.map(contracts.copyPhoneFavorite),
731755 apiKeyUpdate: parsed.apiKeyUpdate.action === "REPLACE"
@@ -733,6 +757,10 @@ function parseCloseFragment(response) {
733757 : { schemaVersion: contracts.SCHEMA_VERSION, action: parsed.apiKeyUpdate.action },
734758 forceFullSync: parsed.forceFullSync === true
735759 };
760+ if (Object.prototype.hasOwnProperty.call(parsed, "languagePreference")) {
761+ update.languagePreference = parsed.languagePreference;
762+ }
763+ return update;
736764 }
737765
738766 function configurationPageState(value, language) {
@@ -740,6 +768,7 @@ function configurationPageState(value, language) {
740768 schemaVersion: contracts.CONFIGURATION_VERSION,
741769 hasKey: value.primApiKey !== null,
742770 favorites: value.favorites.map(contracts.copyPhoneFavorite),
771+ languagePreference: value.languagePreference,
743772 language: typeof language === "string" && language.length > 0 ? language : "en"
744773 };
745774 }
@@ -764,6 +793,7 @@ function configurationUrl(baseUrl, value, language) {
764793
765794 module.exports = {
766795 CONFIG_STORAGE_KEY: CONFIG_STORAGE_KEY,
796+ CONFIG_SCHEMA_VERSION: CONFIG_SCHEMA_VERSION,
767797 RESULTS_STORAGE_KEY: RESULTS_STORAGE_KEY,
768798 INVALID_KEY_STATUS_STORAGE_KEY: INVALID_KEY_STATUS_STORAGE_KEY,
769799 MAX_CLOSE_RESPONSE_LENGTH: MAX_CLOSE_RESPONSE_LENGTH,
@@ -776,6 +806,7 @@ module.exports = {
776806 areFavoritesSecretFree: areFavoritesSecretFree,
777807 normalizeLanguage: normalizeLanguage,
778808 activeWatchLanguage: activeWatchLanguage,
809+ effectiveWatchLanguage: effectiveWatchLanguage,
779810 loadConfiguration: loadConfiguration,
780811 saveConfiguration: saveConfiguration,
781812 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.MISSING55+ 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.keyStatus65+ 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_VERSION180+ || 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_VERSION195 || 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 replaceable203 // 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_VERSION221 && 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: keyStatus722+ 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 === 0734 || 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 === true758 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 @@
11 "use strict";
22
33 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
66 // keep SCHEMA_VERSION 1. Versioning the envelope also protects an empty
77 // backup: a phone that cannot read v2 must never blank the configuration with
88 // 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 the4+// CONFIGURATION_VERSION 2 covers the page open/close envelopes. The companion
5-// page open/close envelopes. Domain objects, nested favorites and ApiKeyUpdate5+// versions its phone-local stored record separately. Domain objects and ApiKeyUpdate
6 // keep SCHEMA_VERSION 1. Versioning the envelope also protects an empty6 // keep SCHEMA_VERSION 1. Versioning the envelope also protects an empty
7 // backup: a phone that cannot read v2 must never blank the configuration with7 // 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 () {
306306 var language;
307307 var url;
308308 if (!this._configurationValid) return;
309- language = configuration.activeWatchLanguage(this._Pebble);
309+ language = configuration.effectiveWatchLanguage(this._Pebble, this._configuration.languagePreference);
310310 url = configuration.configurationUrl(this._configurationUrl, this._configuration, language);
311311 if (url !== null && typeof this._Pebble.openURL === "function") this._Pebble.openURL(url);
312312 };
@@ -328,12 +328,15 @@ Companion.prototype._onWebviewClosed = function (event) {
328328 if (this._credentialBlocked
329329 || this._configuration.keyStatus === contracts.KEY_STATUS.INVALID) {
330330 pending = {
331- schemaVersion: contracts.CONFIGURATION_VERSION,
331+ schemaVersion: configuration.CONFIG_SCHEMA_VERSION,
332332 favorites: next.favorites.map(contracts.copyPhoneFavorite),
333333 primApiKey: next.primApiKey,
334- keyStatus: contracts.KEY_STATUS.INVALID
334+ keyStatus: contracts.KEY_STATUS.INVALID,
335+ languagePreference: next.languagePreference
335336 };
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;
337340 if (!configuration.clearInvalidKeyStatus(this._storage)
338341 || !configuration.saveConfiguration(this._storage, next)) next = pending;
339342 } else if (!configuration.clearInvalidKeyStatus(this._storage)
@@ -349,7 +352,7 @@ Companion.prototype._onWebviewClosed = function (event) {
349352 nextWatch = next.favorites.map(contracts.copyFavorite);
350353 wireChanged = JSON.stringify(nextWatch) !== JSON.stringify(this._watchFavorites)
351354 || next.keyStatus !== this._configuration.keyStatus
352- || configuration.activeWatchLanguage(this._Pebble) !== this._watchLanguage;
355+ || configuration.effectiveWatchLanguage(this._Pebble, next.languagePreference) !== this._watchLanguage;
353356 invalidate = update.apiKeyUpdate.action !== "KEEP"
354357 || !sameFavoriteContentSet(this._watchFavorites, nextWatch)
355358 || this._configuration.favorites.some(function (favorite) {
@@ -381,7 +384,7 @@ Companion.prototype._sendConfiguration = function (forceFull) {
381384 var binding = this._sync.binding();
382385 if (!this._configurationValid || !binding.epoch
383386 || !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);
385388 var records;
386389 try {
387390 records = this._configuration.favorites.map(function (favorite) {
@@ -782,10 +785,11 @@ Companion.prototype._hydrateRouting = function (flight, favorites, complete) {
782785 if (remaining !== 0) return;
783786 if (Object.keys(recovered).length === 0) { complete(recovered); return; }
784787 hydrated = {
785- schemaVersion: contracts.CONFIGURATION_VERSION,
788+ schemaVersion: configuration.CONFIG_SCHEMA_VERSION,
786789 favorites: self._configuration.favorites.map(contracts.copyPhoneFavorite),
787790 primApiKey: self._configuration.primApiKey,
788- keyStatus: self._configuration.keyStatus
791+ keyStatus: self._configuration.keyStatus,
792+ languagePreference: self._configuration.languagePreference
789793 };
790794 hydrated.favorites.forEach(function (favorite) {
791795 if (!favorite.routing && recovered[favorite.serviceId] && favorites.some(function (captured) {
@@ -800,7 +804,7 @@ Companion.prototype._hydrateRouting = function (flight, favorites, complete) {
800804 return;
801805 }
802806 if (changed && configuration.configurationUrl(
803- self._configurationUrl, hydrated, configuration.activeWatchLanguage(self._Pebble)
807+ self._configurationUrl, hydrated, configuration.effectiveWatchLanguage(self._Pebble, hydrated.languagePreference)
804808 ) !== null && configuration.saveConfiguration(self._storage, hydrated)
805809 && flight.generation === self._lifecycleGeneration) {
806810 self._configuration = hydrated;
@@ -1028,9 +1032,10 @@ Companion.prototype._startOverviewFlight = function (request) {
10281032 if (result !== null) {
10291033 groups[serviceId] = result.group;
10301034 var hydrated = {
1031- schemaVersion: contracts.CONFIGURATION_VERSION,
1035+ schemaVersion: configuration.CONFIG_SCHEMA_VERSION,
10321036 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
10341039 };
10351040 var changed = false;
10361041 hydrated.favorites.forEach(function (favorite) {
@@ -1043,7 +1048,8 @@ Companion.prototype._startOverviewFlight = function (request) {
10431048 changed = true;
10441049 });
10451050 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
10471053 && configuration.saveConfiguration(self._storage, hydrated) && current()) {
10481054 self._configuration = hydrated;
10491055 self._sync.enrichPhoneFavorites(hydrated.favorites, flight.generation);
@@ -1148,10 +1154,11 @@ Companion.prototype._invalidateCredential = function () {
11481154 if (this._configuration.primApiKey === null
11491155 || this._configuration.keyStatus === contracts.KEY_STATUS.INVALID) return;
11501156 invalid = {
1151- schemaVersion: contracts.CONFIGURATION_VERSION,
1157+ schemaVersion: configuration.CONFIG_SCHEMA_VERSION,
11521158 favorites: this._configuration.favorites.map(contracts.copyPhoneFavorite),
11531159 primApiKey: this._configuration.primApiKey,
1154- keyStatus: contracts.KEY_STATUS.INVALID
1160+ keyStatus: contracts.KEY_STATUS.INVALID,
1161+ languagePreference: this._configuration.languagePreference
11551162 };
11561163 configuration.saveInvalidConfiguration(this._storage, invalid);
11571164 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._credentialBlocked328 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.INVALID334+ 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.keyStatus354 || 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.epoch385 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.keyStatus791+ 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.keyStatus1037+ 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)) !== null1051+ && 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 === null1154 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.INVALID1160+ 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) {
3030 return copy;
3131 }
3232
33-function configuredStorage(favorites, status) {
33+function configuredStorage(favorites, status, languagePreference) {
3434 var storage = new fakes.FakeStorage();
3535 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
3839 }), true);
3940 storage.writes.length = 0;
4041 return storage;
@@ -208,13 +209,19 @@ function resolveProduction(target, favorites) {
208209 } else respond(target, xhr, 200, trafficBody(favorites));
209210 });
210211 }
211-function closeWith(target, action, favorites, value, forceFull) {
212+function closeWith(target, action, favorites, value, forceFull, languagePreference) {
212213 var update = { schemaVersion: 2, favorites: favorites, apiKeyUpdate: { schemaVersion: 1, action: action } };
213214 if (action === "REPLACE") update.apiKeyUpdate.value = value;
214215 if (forceFull) update.forceFullSync = true;
216+ if (typeof languagePreference !== "undefined") update.languagePreference = languagePreference;
215217 target.Pebble.emit("webviewclosed", { response: "pebblejs://close#" + encodeURIComponent(JSON.stringify(update)) });
216218 }
217219
220+function openConfiguration(target) {
221+ target.Pebble.emit("showConfiguration");
222+ return JSON.parse(decodeURIComponent(target.Pebble.openedUrls.at(-1).split("#")[1]));
223+}
224+
218225 test("Core Android decoded close responses preserve favorites with line colors", function () {
219226 var target = harness({ storage: configuredStorage([]) });
220227 var update = {
@@ -229,6 +236,108 @@ test("Core Android decoded close responses preserve favorites with line colors",
229236 target.companion.stop();
230237 });
231238
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+
232341 function serviceRow(favorite) {
233342 return { serviceId: favorite.serviceId, stopLabel: favorite.stopLabel, lineLabel: favorite.lineLabel,
234343 destinationLabel: favorite.destinationLabel, lineMode: favorite.lineMode,
@@ -438,7 +547,8 @@ test("the failure table deterministically projects unconfigured, stale, or unava
438547 if (failure.keyStatus === contracts.KEY_STATUS.MISSING) {
439548 storage = new fakes.FakeStorage();
440549 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"
442552 }), true);
443553 } else storage = configuredStorage([FIRST], failure.keyStatus);
444554 if (!failure.noCache) seedOverview(storage, [FIRST], clock);
@@ -571,7 +681,7 @@ test("superseded settled selections get terminal data without launching obsolete
571681 target.companion.stop();
572682 });
573683
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 () {
575685 var favorites = [FIRST, phoneFavorite(2, 1)], storage = configuredStorage(favorites), clock = new fakes.FakeClock();
576686 seedOverview(storage, favorites, clock);
577687 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
583693 sendTraffic(target, "same-line", favorites[1]);
584694 assert.equal(requests(target, "traffic").length, 1);
585695 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);
587699 sendTraffic(target, "translated", FIRST);
588700 assert.equal(requests(target, "traffic").length, 2);
589701 assert.equal(requests(target, "traffic")[1].headers["Accept-Language"], undefined);
590702 resolveProduction(target, favorites);
591703 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");
592754 target.companion.stop();
593755 });
594756
@@ -629,7 +791,7 @@ test("explicit FULL is one-shot, sends complete inventory, and is not persisted"
629791
630792 test("unresolved routing uses a pinned public revision once per service without metadata resynchronization", function () {
631793 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 });
633795 ready(target); var before = messages(target, T.CONFIG_BEGIN).length;
634796 sendOverview(target, "hydrate");
635797 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
637799 requests(target, "static").forEach(function (xhr) { assert.equal(xhr.headers.apikey, undefined); assert.equal(xhr.headers.Authorization, undefined); });
638800 assert.equal(requests(target, "departures").length, 1);
639801 assert.deepEqual(configuration.loadConfiguration(storage).favorites[1].routing, FIRST.routing);
802+ assert.equal(openConfiguration(target).languagePreference, "fr");
640803 assert.equal(storage.writes.filter(function (write) { return write.key === configuration.CONFIG_STORAGE_KEY; }).length, 1);
641804 assert.equal(messages(target, T.CONFIG_BEGIN).length, before);
642805 resolveProduction(target, [FIRST]);
@@ -753,11 +916,12 @@ test("catalog revision mismatch cannot hydrate favorites or send credentials to
753916 });
754917
755918 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") });
757920 ready(target); sendOverview(target, "old-screen"); sendTraffic(target, "current-screen", SECOND);
758921 respond(target, requests(target, "departures")[0], 401, "");
759922 assert.equal(configuration.loadConfiguration(target.storage).keyStatus, contracts.KEY_STATUS.INVALID);
760923 assert.deepEqual(configuration.loadConfiguration(target.storage).favorites[0].routing, FIRST.routing);
924+ assert.equal(configuration.loadConfiguration(target.storage).languagePreference, "fr");
761925 assert.equal(requests(target, "departures")[1].aborted, true); assert.equal(requests(target, "traffic")[0].aborted, true);
762926 acknowledgeConfiguration(target, 0);
763927 assert.equal(rows(target, "old-screen")[0].error, 3);
@@ -766,6 +930,7 @@ test("authentication rejection invalidates credentials durably, cancels network,
766930 sendOverview(target, "blocked"); target.clock.advance(3600000);
767931 assert.equal(target.xhr.instances.length, before); assert.equal(rows(target, "blocked")[0].error, 3);
768932 target.companion.stop(); target = harness({ storage: target.storage }); ready(target, 0);
933+ assert.equal(target.binding.LANGUAGE, "fr");
769934 sendOverview(target, "blocked-after-restart"); assert.equal(target.xhr.instances.length, 0);
770935 assert.equal(rows(target, "blocked-after-restart")[0].error, 3); target.companion.stop();
771936 });
@@ -780,6 +945,42 @@ test("the invalid-key journal blocks restart even when the primary configuration
780945 assert.deepEqual(finalTransfer(target, "restart").records, ["e03"]); target.companion.stop();
781946 });
782947
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+
783984 test("credential replacement ignores saved callbacks from the canceled lifecycle", function () {
784985 var target = harness(); ready(target); sendTraffic(target, "old-key");
785986 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
8101011
8111012 test("interrupted same-key replacement never clears durable invalid-key authority", function () {
8121013 ["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+ };
8141019 ready(target); sendTraffic(target, "invalid-before-replacement"); respond(target, requests(target, "traffic")[0], 401, "");
8151020 storage.setItem = function (key, value) {
8161021 if (failure === "configured-write" && key === configuration.CONFIG_STORAGE_KEY && JSON.parse(value).keyStatus === contracts.KEY_STATUS.CONFIGURED) throw new Error("interrupted");
8171022 write.call(this, key, value);
8181023 };
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- };
8231024 closeWith(target, "REPLACE", [FIRST], TEST_KEY); target.companion.stop();
8241025 assert.equal(configuration.loadConfiguration(storage).keyStatus, contracts.KEY_STATUS.INVALID);
1026+ assert.equal(configuration.loadConfiguration(storage).languagePreference, "fr");
8251027 });
8261028 });
8271029
@@ -986,7 +1188,7 @@ function finishCaptured(target, call, favorite, seconds) {
9861188
9871189 test("journey flights migrate a single null arrival through real annexes before PRIM and preserve labels", function () {
9881190 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 });
9901192 ready(target);
9911193 var calls = captureDepartures(target);
9921194 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
10011203 var saved = configuration.loadConfiguration(target.storage).favorites[0];
10021204 assert.equal(saved.arrivalPlaceId, resolved.arrivalPlaceId);
10031205 assert.equal(saved.destinationLabel, "Historic label");
1206+ assert.equal(openConfiguration(target).languagePreference, "fr");
10041207 finishCaptured(target, calls[0], resolved, 180);
10051208 respond(target, requests(target, "traffic")[0], 200, trafficBody([resolved]));
10061209 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.CONFIGURED37+ 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.MISSING550+ 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");
99 const KEY = "journey-storage-fixture-key";
1010 const ARRIVAL = "plc_" + "a".repeat(43);
1111 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) {
1313 const favorite = { ...fixture.favorite, arrivalPlaceId: ARRIVAL, routing: { ...ROUTING } };
1414 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;
1618 }
1719 function storageFor(value) {
1820 const storage = new FakeStorage();
@@ -20,33 +22,37 @@ function storageFor(value) {
2022 return storage;
2123 }
2224
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+ }
3237 });
3338
3439 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]) {
3641 const value = record(version);
3742 value.favorites[0].arrivalPlaceId = "not-a-place";
3843 const storage = storageFor(value), before = storage.getItem(configuration.CONFIG_STORAGE_KEY);
3944 assert.equal(configuration.loadConfiguration(storage), null);
4045 assert.equal(storage.getItem(configuration.CONFIG_STORAGE_KEY), before);
4146 }
42- const value = record(); delete value.favorites[0].arrivalPlaceId;
47+ const value = record(2); delete value.favorites[0].arrivalPlaceId;
4348 assert.equal(configuration.loadConfiguration(storageFor(value)), null);
4449 const inherited = record(); delete inherited.favorites[0].arrivalPlaceId;
4550 Object.setPrototypeOf(inherited.favorites[0], { arrivalPlaceId: ARRIVAL });
4651 assert.equal(configuration.isStoredConfiguration(inherited), false);
4752 const explicit = record(1);
4853 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" });
5056 });
5157
5258 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
5763 assert.equal(configuration.loadConfiguration(storageFor(value)), null);
5864 });
5965
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+ }
7585 });
7686
7787 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
98108 test("v2 page envelopes preserve arrivals while a legacy empty close can never clear favorites", () => {
99109 const current = record();
100110 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" });
102113 const update = { schemaVersion: 2, favorites: current.favorites, apiKeyUpdate: { schemaVersion: 1, action: "KEEP" } };
103114 const parsed = configuration.parseCloseFragment("pebblejs://close#" + encodeURIComponent(JSON.stringify(update)));
104115 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
108119 assert.equal(configuration.areFavoritesSecretFree(current.favorites, "aaa", null), false);
109120 });
110121
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+
111169 function overview(favorites) {
112170 const { schemaVersion, requestId, favoriteId, ...snapshot } = fixture.result;
113171 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 @@
124124 </div>
125125 </details>
126126
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+
127149 <details class="settings-section" id="sync-section" aria-labelledby="sync-title">
128150 <summary>
129151 <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 @@
1111 //
1212 // Canonical contract: packages/contracts/src/index.ts. Domain objects, the
1313 // 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
1616 // is always rejected by the phone, so an outdated page can never wipe the
1717 // stored configuration. The mirrored constants below are asserted equal to
1818 // 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";
2626
2727 export const SCHEMA_VERSION = 1;
2828
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.
3131 export const CONFIGURATION_VERSION = 2;
3232
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+
3340 // Journey place identities are catalog-computed plc_ + SHA-256 base64url;
3441 // the page only validates their form.
3542 const PLACE_ID = /^plc_[A-Za-z0-9_-]{43}$/;
@@ -172,6 +179,13 @@ export const COPY = {
172179 syncCaption: "Transfer options",
173180 syncHint: "The watch normally receives only what changed. Tick this to resend everything if the watch shows outdated favorites.",
174181 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.",
175189 saveTooLarge: "These settings are too large to transfer. Remove the favorite with the longest entry and add it again.",
176190 configurationUpgradeRequired: "Update Lapin Futé on your Pebble to edit these settings.",
177191 },
@@ -241,6 +255,13 @@ export const COPY = {
241255 syncCaption: "Options de transfert",
242256 syncHint: "La montre ne reçoit normalement que les changements. Cochez cette case pour tout renvoyer si la montre affiche des favoris obsolètes.",
243257 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.",
244265 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.",
245266 configurationUpgradeRequired: "Mettez à jour Lapin Futé sur votre Pebble pour modifier ces réglages.",
246267 },
@@ -458,6 +479,7 @@ function renumber(favorites) {
458479 // --- Opening fragment --------------------------------------------------------
459480 // Expected shapes (all values non-secret):
460481 // #<encodeURIComponent(JSON.stringify({ schemaVersion: 2, hasKey, favorites, language }))>
482+// #<encodeURIComponent(JSON.stringify({ schemaVersion: 2, hasKey, favorites, language, languagePreference }))>
461483 // #<encodeURIComponent(JSON.stringify({ hasKey, favorites, language }))> (old phone)
462484 // hasKey is the only credential-related field; the stored key itself is never
463485 // present. A complete v2 envelope with only valid favorites opens an editable
@@ -465,21 +487,37 @@ function renumber(favorites) {
465487 // display only: it is never hydrated, never converted, and never editable —
466488 // the watch app must be updated. An absent or malformed fragment is likewise
467489 // 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.
468496
469497 export const MAX_OPENING_FRAGMENT_LENGTH = 32768;
470-const OPENING_FIELDS = ["schemaVersion", "hasKey", "favorites", "language"];
498+const OPENING_FIELDS = ["schemaVersion", "hasKey", "favorites", "language", "languagePreference"];
471499
472500 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+ };
474510 }
475511
476-function openingState(hasKey, language, favorites, editable) {
512+function openingState(hasKey, language, favorites, editable, languagePreference = undefined) {
477513 return {
478514 hasKey,
479515 language,
480516 locale: selectLocale(language),
481517 favorites,
482518 editable,
519+ languagePreference: languagePreference === undefined ? "auto" : languagePreference,
520+ languagePreferenceSupported: languagePreference !== undefined,
483521 };
484522 }
485523
@@ -501,6 +539,8 @@ export function parseConfigFragment(hash) {
501539 || !Array.isArray(parsed.favorites)
502540 || typeof parsed.language !== "string") return emptyOpeningState();
503541 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();
504544 if (parsed.schemaVersion === CONFIGURATION_VERSION) {
505545 // An invalid v2 favorite never yields an editable subset: the session
506546 // stays read-only, and only entries that are individually valid are
@@ -511,6 +551,7 @@ export function parseConfigFragment(hash) {
511551 parsed.language,
512552 parsed.favorites.filter(isPhoneFavorite).map(copyPhoneFavorite),
513553 false,
554+ parsed.languagePreference,
514555 );
515556 }
516557 return openingState(
@@ -518,10 +559,13 @@ export function parseConfigFragment(hash) {
518559 parsed.language,
519560 parsed.favorites.map(copyPhoneFavorite),
520561 true,
562+ parsed.languagePreference,
521563 );
522564 }
523565 if (parsed.schemaVersion === undefined || parsed.schemaVersion === SCHEMA_VERSION) {
524566 // 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.
525569 return openingState(
526570 parsed.hasKey,
527571 parsed.language,
@@ -545,6 +589,12 @@ export function initialConfigState(query) {
545589 // Fail closed: only an explicitly editable session (a complete v2
546590 // opening) may mutate anything or save.
547591 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,
548598 };
549599 }
550600
@@ -643,6 +693,14 @@ export function reduceConfigState(state, action) {
643693 if (!favoritesFitCloseBound(candidate)) return state;
644694 return { ...state, favorites: candidate };
645695 }
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+ }
646704 case "force-full-sync":
647705 if (state.editable !== true) return state;
648706 return { ...state, forceFullSync: action.value === true };
@@ -694,6 +752,14 @@ export function planConfigResult(state) {
694752 apiKeyUpdate,
695753 favorites: state.favorites.map(stampFavorite),
696754 };
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+ }
697763 // One-shot full resync: absent unless explicitly requested on the settings page.
698764 if (state.forceFullSync === true) payload.forceFullSync = true;
699765 return { ok: true, payload };
@@ -760,9 +826,9 @@ export function closePayloadFits(payload) {
760826 // becomes six encoded characters (quotes behave identically; canonical
761827 // boundedString excludes control characters, and every other byte inflates
762828 // 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.
766832
767833 function favoritesFitCloseBound(favorites) {
768834 return closePayloadFits({
@@ -773,6 +839,7 @@ function favoritesFitCloseBound(favorites) {
773839 value: "\\".repeat(LIMITS.apiKeyUtf8Bytes),
774840 },
775841 favorites,
842+ languagePreference: "auto",
776843 forceFullSync: true,
777844 });
778845 }
@@ -11,8 +11,8 @@
11 //11 //
12 // Canonical contract: packages/contracts/src/index.ts. Domain objects, the12 // Canonical contract: packages/contracts/src/index.ts. Domain objects, the
13 // favorites nested inside every envelope, and ApiKeyUpdate keep13 // favorites nested inside every envelope, and ApiKeyUpdate keep
14-// SCHEMA_VERSION 1. Only the stored configuration and the page open/close14+// SCHEMA_VERSION 1. Page open/close envelopes use CONFIGURATION_VERSION 2;
15-// envelopes use CONFIGURATION_VERSION 2; a close fragment on the old version15+// 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 the16 // is always rejected by the phone, so an outdated page can never wipe the
17 // stored configuration. The mirrored constants below are asserted equal to17 // stored configuration. The mirrored constants below are asserted equal to
18 // the canonical module by test/config-page.test.ts; never change one side18 // 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 page29+// 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 never484 // 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 editable485 // 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 likewise488 // 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 session545 // An invalid v2 favorite never yields an editable subset: the session
506 // stays read-only, and only entries that are individually valid are546 // 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 v2589 // 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; canonical826 // becomes six encoded characters (quotes behave identically; canonical
761 // boundedString excludes control characters, and every other byte inflates827 // boundedString excludes control characters, and every other byte inflates
762 // at most threefold). A 512-byte key of backslashes therefore bounds every828 // 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 envelope829+// 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 checked830+// 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([
3333 "force-sync-label", "force-sync", "config-footer", "about-open", "about-view", "about-back",
3434 "about-title", "about-en", "about-fr",
3535 "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",
3638 "arrival-step", "arrivals-label", "arrival-select", "journey-hint", "configuration-upgrade",
3739 ].map((id) => [id.replaceAll("-", "_"), byId(id)]));
3840
@@ -119,6 +121,7 @@ function dispatch(action) {
119121 state = reduceConfigState(state, action);
120122 renderKey();
121123 renderFavorites();
124+ renderLanguage();
122125 }
123126
124127 function applyCopy() {
@@ -142,6 +145,11 @@ function applyCopy() {
142145 ["syncTitle", elements.sync_title], ["syncHint", elements.sync_hint],
143146 ["syncCaption", elements.sync_caption],
144147 ["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],
145153 ]) setText(element, copy[key]);
146154 elements.key_input.placeholder = copy.keyPlaceholder;
147155 elements.place_search.placeholder = copy.searchPlaceholder;
@@ -164,6 +172,19 @@ function renderKey() {
164172 setText(elements.key_toggle, keyRevealed ? copy.keyHide : copy.keyShow);
165173 }
166174
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+
167188 function actionButton(label, disabled, action, className = "quiet") {
168189 const button = document.createElement("button");
169190 button.type = "button";
@@ -674,6 +695,10 @@ elements.force_sync.addEventListener("change", () => {
674695 dispatch({ type: "force-full-sync", value: elements.force_sync.checked });
675696 });
676697
698+elements.language_select.addEventListener("change", () => {
699+ dispatch({ type: "language-preference", value: elements.language_select.value });
700+});
701+
677702 elements.save.addEventListener("click", () => {
678703 if (!state.editable) return;
679704 const keyError = apiKeyError(state.keyDraft.value);
@@ -709,7 +734,7 @@ elements.save.addEventListener("click", () => {
709734 });
710735
711736 // 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.
713738 // The key draft, favorites, and catalog data never reach storage, and
714739 // unavailable or corrupt storage (private mode, blocked cookies, cleared
715740 // origins) leaves the markup defaults in place and the page functional.
@@ -718,6 +743,7 @@ const sectionElements = [
718743 elements.prim_section,
719744 elements.favorites_section,
720745 elements.add_section,
746+ elements.language_section,
721747 elements.sync_section,
722748 ];
723749
@@ -766,6 +792,7 @@ window.addEventListener("pagehide", () => {
766792
767793 applyCopy();
768794 renderKey();
795+renderLanguage();
769796 renderFavorites();
770797 elements.configuration_upgrade.hidden = state.editable;
771798 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, and738 // The key draft, favorites, and catalog data never reach storage, and
714 // unavailable or corrupt storage (private mode, blocked cookies, cleared739 // 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: {} };
44 "use strict";
55
66 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
99 // keep SCHEMA_VERSION 1. Versioning the envelope also protects an empty
1010 // backup: a phone that cannot read v2 must never blank the configuration with
1111 // 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 the7+// CONFIGURATION_VERSION 2 covers the page open/close envelopes. The companion
8-// page open/close envelopes. Domain objects, nested favorites and ApiKeyUpdate8+// versions its phone-local stored record separately. Domain objects and ApiKeyUpdate
9 // keep SCHEMA_VERSION 1. Versioning the envelope also protects an empty9 // keep SCHEMA_VERSION 1. Versioning the envelope also protects an empty
10 // backup: a phone that cannot read v2 must never blank the configuration with10 // 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; }
263263 .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; }
264264 .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); }
265265 #sync-hint { margin-top: 0.75rem; }
266+#language-hint, #language-unsupported { margin-top: 0.75rem; }
266267
267268 /* The sticky save tray stays in document flow, so the final section can
268269 * 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 can268 /* 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 {
132132 return withoutArrival;
133133 }
134134
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+};
136144
137145 function openingFragment(value: Record<string, unknown>): string {
138146 return `#${encodeURIComponent(JSON.stringify(value))}`;
@@ -154,6 +162,20 @@ function legacyFragmentWith(favorites: unknown[], overrides: Record<string, unkn
154162 return openingFragment({ hasKey: true, favorites, language: "fr_FR", ...overrides });
155163 }
156164
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+
157179 test("mirrored constants equal the canonical contract", () => {
158180 assert.equal(SCHEMA_VERSION, CANONICAL_SCHEMA_VERSION);
159181 assert.equal(SCHEMA_VERSION, 1);
@@ -332,7 +354,7 @@ test("the config page consumes the companion-produced opening fragment exactly",
332354 const key = "stored-personal-key";
333355 const url = companionConfiguration.configurationUrl(
334356 "https://config.example.test/index.html",
335- { schemaVersion: CONFIGURATION_VERSION, favorites: fixtureList, primApiKey: key, keyStatus: 1 },
357+ storedConfiguration({ favorites: fixtureList, primApiKey: key }),
336358 "fr_FR",
337359 );
338360 assert.equal(typeof url, "string");
@@ -340,11 +362,15 @@ test("the config page consumes the companion-produced opening fragment exactly",
340362 assert.equal(url.includes(key), false);
341363
342364 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+ );
344369 assert.deepEqual(openingState, {
345370 schemaVersion: CONFIGURATION_VERSION,
346371 hasKey: true,
347372 favorites: fixtureList,
373+ languagePreference: "auto",
348374 language: "fr_FR",
349375 });
350376 assert.deepEqual(parseConfigFragment(new URL(url).hash), {
@@ -353,9 +379,151 @@ test("the config page consumes the companion-produced opening fragment exactly",
353379 locale: "fr",
354380 favorites: fixtureList,
355381 editable: true,
382+ languagePreference: "auto",
383+ languagePreferenceSupported: true,
356384 });
357385 });
358386
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+
359527
360528 test("a planted key field never enters page state or the payload", () => {
361529 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
8461014
8471015 // The companion-produced opening fragment carries routing and arrivals and
8481016 // 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 });
8551018 const url = companionConfiguration.configurationUrl(
8561019 "https://config.example.test/index.html",
8571020 stored,
@@ -1052,12 +1215,7 @@ test("encoded close and opening fragments enforce the 32768 bound at producers",
10521215 assert.equal(companionConfiguration.parseCloseFragment(encodeCloseFragment(bloated)), null);
10531216
10541217 // 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] });
10611219 assert.equal(companionConfiguration.isStoredConfiguration(oversizedConfig), true);
10621220 assert.equal(
10631221 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", () => {
11161274 assert.doesNotMatch(client, /prim\.iledefrance-mobilites/u);
11171275 assert.match(html, /type="password" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"/u);
11181276 assert.match(html, /connect-src 'self'/u);
1119- assert.doesNotMatch(html, /value=["'][^"']+["']/u);
11201277 assert.match(html, /img-src 'self'/u);
11211278 assert.doesNotMatch(html, /img-src 'none'/u);
11221279 });
@@ -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 and1015 // 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 @@
11 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
44 // keep SCHEMA_VERSION 1. Versioning the envelope also protects an empty
55 // backup: a phone that cannot read v2 must never blank the configuration with
66 // 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 the2+// CONFIGURATION_VERSION 2 covers the page open/close envelopes. The companion
3-// page open/close envelopes. Domain objects, nested favorites and ApiKeyUpdate3+// versions its phone-local stored record separately. Domain objects and ApiKeyUpdate
4 // keep SCHEMA_VERSION 1. Versioning the envelope also protects an empty4 // keep SCHEMA_VERSION 1. Versioning the envelope also protects an empty
5 // backup: a phone that cannot read v2 must never blank the configuration with5 // 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 } = {}) {
3939 routing: { ...ROUTING }
4040 }));
4141 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
4344 }));
4445 let cache = configuration.emptyCache();
4546 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: KEY42+ 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) {