anthodev/lapin-futepublic Fork
⑂ aabca5b
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: deliver Pebble watch consultation (#15)Unverified

Cédric Anthony committed 2026-09-04T18:21:56+02:00 Browse files
aabca5b parent: 5ff79d1
modified packages/companion/src/index.js +273 -104
@@ -38,16 +38,28 @@ function normalizeIncomingPayload(payload) {
3838 }
3939 return aliasCount > 0 ? normalized : null;
4040 }
41-
42-function isFixture(value) {
43- return contracts.isObject(value)
44- && contracts.hasOnlyKeys(value, ["favorite", "result"])
45- && contracts.isFavorite(value.favorite)
46- && contracts.isDepartureResult(value.result)
47- && value.favorite.id === value.result.favoriteId;
41+function visibleResultSignature(result) {
42+ return JSON.stringify({
43+ favoriteId: result.favoriteId,
44+ fetchedAt: result.fetchedAt,
45+ freshness: result.freshness,
46+ departures: result.departures.slice(0, 3).map(function (departure) {
47+ var visible = {
48+ expectedAt: departure.expectedAt,
49+ status: departure.status
50+ };
51+ if (hasOwn.call(departure, "nextIntervalMinutes")) {
52+ visible.nextIntervalMinutes = departure.nextIntervalMinutes;
53+ }
54+ return visible;
55+ })
56+ });
4857 }
4958
59+
60+
5061 function Companion(options) {
62+ var self = this;
5163 options = options || {};
5264 requiredAdapter(options.Pebble && typeof options.Pebble.addEventListener === "function", "Pebble adapter is required");
5365 requiredAdapter(options.storage
@@ -58,18 +70,15 @@ function Companion(options) {
5870 requiredAdapter(options.clock
5971 && typeof options.clock.now === "function", "clock adapter is required");
6072 requiredAdapter(typeof options.defer === "function", "defer adapter is required");
61- if (typeof options.fixture !== "undefined" && !isFixture(options.fixture)) {
62- throw new TypeError("fixture does not match the frozen contracts");
63- }
73+ requiredAdapter(typeof options.readyDefer === "function", "readyDefer adapter is required");
6474
6575 this._Pebble = options.Pebble;
6676 this._storage = options.storage;
6777 this._XHR = options.XHR;
6878 this._clock = options.clock;
79+ this._readyDefer = options.readyDefer;
6980 this._backendUrl = typeof options.backendUrl === "string" ? options.backendUrl : "";
7081 this._configurationUrl = typeof options.configurationUrl === "string" ? options.configurationUrl : "";
71- this._fixture = options.fixture || null;
72- this._fixtureEnabled = false;
7382 this._configuration = configuration.loadConfiguration(this._storage);
7483 this._configurationValid = this._configuration !== null;
7584 this._credentialBlocked = this._configurationValid
@@ -86,12 +95,18 @@ function Companion(options) {
8695 totalLatencyMs: 0,
8796 lastLatencyMs: 0
8897 };
89- this._requestGenerations = Object.create(null);
90- this._inFlightRequests = Object.create(null);
91- this._requestGeneration = 0;
98+ this._lifecycleGeneration = 0;
99+ this._inFlightByFavorite = Object.create(null);
100+ this._latestWatchFavoriteId = null;
101+ this._latestWatchRequestId = null;
102+ this._presentationGeneration = 0;
103+ this._pendingResultBatches = [];
104+ this._visibleResultSignature = null;
92105 this._sequence = 0;
93106 this._started = false;
94- this._queue = new MessageQueue(this._Pebble, options.defer);
107+ this._queue = new MessageQueue(this._Pebble, options.defer, function () {
108+ self._failPendingResultBatches();
109+ });
95110 this._handlers = null;
96111 this._refreshWatchState();
97112 }
@@ -114,7 +129,6 @@ Companion.prototype.start = function () {
114129
115130 Companion.prototype.stop = function () {
116131 var self = this;
117- var inFlightRequests;
118132 if (!this._started) return;
119133 if (typeof this._Pebble.removeEventListener === "function") {
120134 Object.keys(this._handlers).forEach(function (eventName) {
@@ -122,26 +136,80 @@ Companion.prototype.stop = function () {
122136 });
123137 }
124138 this._started = false;
125- this._requestGenerations = Object.create(null);
126- inFlightRequests = this._inFlightRequests;
127- this._inFlightRequests = Object.create(null);
139+ this._invalidateLifecycle();
140+};
141+
142+Companion.prototype._refreshWatchState = function () {
143+ this._watchFavorites = this._configuration.favorites.map(contracts.copyFavorite);
144+};
145+Companion.prototype._resetPresentation = function () {
146+ this._presentationGeneration += 1;
147+ this._pendingResultBatches.forEach(function (batch) {
148+ batch.superseded = true;
149+ });
150+ this._visibleResultSignature = null;
151+};
152+
153+Companion.prototype._setLatestWatchRequest = function (request) {
154+ var sameFavorite = this._latestWatchFavoriteId === request.favoriteId;
155+ if (sameFavorite && this._latestWatchRequestId === request.requestId) return;
156+ this._latestWatchFavoriteId = request.favoriteId;
157+ this._latestWatchRequestId = request.requestId;
158+ if (!sameFavorite) {
159+ this._resetPresentation();
160+ return;
161+ }
162+ this._pendingResultBatches.forEach(function (batch) {
163+ if (batch.favoriteId === request.favoriteId
164+ && batch.requestId !== request.requestId) {
165+ batch.superseded = true;
166+ }
167+ });
168+};
169+
170+Companion.prototype._removePendingResultBatch = function (batch) {
171+ var index = this._pendingResultBatches.indexOf(batch);
172+ if (index !== -1) this._pendingResultBatches.splice(index, 1);
173+};
174+
175+Companion.prototype._completePendingResultBatch = function (batch) {
176+ this._removePendingResultBatch(batch);
177+ if (batch.failed
178+ || batch.superseded
179+ || batch.generation !== this._presentationGeneration
180+ || !this._isCurrentWatchRequest(batch.request)) return;
181+ this._visibleResultSignature = batch.signature;
182+};
183+
184+Companion.prototype._failPendingResultBatches = function () {
185+ var batches = this._pendingResultBatches;
186+ this._pendingResultBatches = [];
187+ batches.forEach(function (batch) {
188+ batch.failed = true;
189+ if (batch.mirrored) batch.request.transportEligible = false;
190+ });
191+};
192+
193+Companion.prototype._invalidateLifecycle = function () {
194+ var inFlight = this._inFlightByFavorite;
195+ this._lifecycleGeneration += 1;
196+ this._inFlightByFavorite = Object.create(null);
197+ this._latestWatchFavoriteId = null;
198+ this._latestWatchRequestId = null;
128199 this._queue.clear();
129- Object.keys(inFlightRequests).forEach(function (generation) {
130- var xhr = inFlightRequests[generation];
131- if (!xhr || typeof xhr.abort !== "function") return;
200+ this._pendingResultBatches = [];
201+ this._resetPresentation();
202+ Object.keys(inFlight).forEach(function (favoriteId) {
203+ var flight = inFlight[favoriteId];
204+ if (!flight.xhr || typeof flight.xhr.abort !== "function") return;
132205 try {
133- xhr.abort();
206+ flight.xhr.abort();
134207 } catch (ignored) {
135- // Generation invalidation remains authoritative if abort is unsupported or races.
208+ // Lifecycle invalidation remains authoritative if abort is unsupported or races.
136209 }
137210 });
138211 };
139212
140-Companion.prototype._refreshWatchState = function () {
141- var configured = this._configuration.favorites.map(contracts.copyFavorite);
142- this._fixtureEnabled = this._fixture !== null && configured.length === 0;
143- this._watchFavorites = this._fixtureEnabled ? [contracts.copyFavorite(this._fixture.favorite)] : configured;
144-};
145213
146214 Companion.prototype._nextSequenceId = function () {
147215 this._sequence += 1;
@@ -150,17 +218,16 @@ Companion.prototype._nextSequenceId = function () {
150218 };
151219
152220 Companion.prototype._sendConfiguration = function () {
153- var keyStatus = this._fixtureEnabled
154- ? contracts.KEY_STATUS.CONFIGURED
155- : (this._credentialBlocked
156- ? contracts.KEY_STATUS.INVALID
157- : this._configuration.keyStatus);
221+ var keyStatus = this._credentialBlocked
222+ ? contracts.KEY_STATUS.INVALID
223+ : this._configuration.keyStatus;
158224 if (!this._configurationValid
159225 || !configuration.areFavoritesSecretFree(
160226 this._watchFavorites,
161227 this._configuration.primApiKey,
162228 null
163229 )) return;
230+ this._resetPresentation();
164231 this._queue.enqueue(codec.encodeConfiguration(
165232 this._nextSequenceId(),
166233 this._watchFavorites,
@@ -170,6 +237,15 @@ Companion.prototype._sendConfiguration = function () {
170237 };
171238
172239 Companion.prototype._onReady = function () {
240+ var self = this;
241+ var generation = this._lifecycleGeneration;
242+ this._readyDefer(function () {
243+ if (!self._started || self._lifecycleGeneration !== generation) return;
244+ self._synchronizeReady();
245+ });
246+};
247+
248+Companion.prototype._synchronizeReady = function () {
173249 var wasBlocked = this._credentialBlocked;
174250 var loaded = configuration.loadConfiguration(this._storage);
175251 if (loaded === null) {
@@ -234,9 +310,9 @@ Companion.prototype._onWebviewClosed = function (event) {
234310 if (JSON.stringify(pruned) !== JSON.stringify(this._results)) {
235311 configuration.saveResults(this._storage, pruned);
236312 }
313+ this._invalidateLifecycle();
237314 this._configuration = next;
238315 this._results = pruned;
239- this._requestGenerations = Object.create(null);
240316 this._credentialBlocked = next.primApiKey !== null
241317 && next.keyStatus === contracts.KEY_STATUS.INVALID;
242318 this._refreshWatchState();
@@ -244,15 +320,25 @@ Companion.prototype._onWebviewClosed = function (event) {
244320 };
245321
246322 Companion.prototype._onAppMessage = function (event) {
323+ var favorite;
324+ var decoded;
247325 var request;
248326 if (!this._configurationValid) return;
249- request = codec.decodeRequest(normalizeIncomingPayload(event && event.payload));
250- if (request === null) return;
251- if (this._findFavorite(request.favoriteId) === null) {
327+ decoded = codec.decodeRequest(normalizeIncomingPayload(event && event.payload));
328+ if (decoded === null) return;
329+ request = {
330+ requestId: decoded.requestId,
331+ favoriteId: decoded.favoriteId,
332+ trigger: decoded.trigger,
333+ transportEligible: true
334+ };
335+ this._setLatestWatchRequest(request);
336+ favorite = this._findFavorite(request.favoriteId);
337+ if (favorite === null) {
252338 this._sendError(request, "INVALID_SERVICE");
253339 return;
254340 }
255- this._dispatchRequest(request);
341+ this._dispatchRequest(request, favorite);
256342 };
257343
258344 Companion.prototype._findFavorite = function (favoriteId) {
@@ -280,40 +366,83 @@ Companion.prototype._cachedResult = function (request) {
280366 return configuration.findResult(this._results, request.favoriteId, request.requestId);
281367 };
282368
283-Companion.prototype._beginRequest = function (favoriteId) {
284- this._requestGeneration += 1;
285- this._requestGenerations[favoriteId] = this._requestGeneration;
286- return this._requestGeneration;
369+Companion.prototype._isCurrentWatchRequest = function (request) {
370+ return this._latestWatchFavoriteId === request.favoriteId
371+ && this._latestWatchRequestId === request.requestId;
287372 };
288373
289-Companion.prototype._isLatestRequest = function (favoriteId, generation) {
290- return this._requestGenerations[favoriteId] === generation;
374+Companion.prototype._enqueueVisibleResult = function (result, mirroredRequest, request) {
375+ var self = this;
376+ var generation = this._presentationGeneration;
377+ var signature = visibleResultSignature(result);
378+ var mirrored = mirroredRequest !== null;
379+ var visible = this._visibleResultSignature;
380+ var messages;
381+ var batch;
382+ var index;
383+ for (index = 0; index < this._pendingResultBatches.length; index += 1) {
384+ batch = this._pendingResultBatches[index];
385+ if (!batch.failed
386+ && !batch.superseded
387+ && batch.generation === generation
388+ && batch.favoriteId === result.favoriteId
389+ && batch.requestId === result.requestId
390+ && batch.signature === signature
391+ && batch.mirrored === mirrored) {
392+ batch.request = request;
393+ return null;
394+ }
395+ }
396+ if (visible === signature) return null;
397+
398+ messages = codec.encodeResult(result);
399+ if (mirrored) messages.push(codec.encodeRequest(mirroredRequest));
400+ for (index = this._pendingResultBatches.length - 1; index >= 0; index -= 1) {
401+ batch = this._pendingResultBatches[index];
402+ if (batch.failed
403+ || batch.generation !== generation
404+ || batch.favoriteId !== result.favoriteId
405+ || batch.signature !== signature
406+ || !this._queue.replacePending(batch.queueBatchId, messages)) continue;
407+ batch.requestId = result.requestId;
408+ batch.request = request;
409+ batch.mirrored = mirrored;
410+ batch.superseded = false;
411+ return batch;
412+ }
413+
414+ batch = {
415+ generation: generation,
416+ signature: signature,
417+ requestId: result.requestId,
418+ favoriteId: result.favoriteId,
419+ request: request,
420+ mirrored: mirrored,
421+ failed: false,
422+ superseded: false,
423+ queueBatchId: null
424+ };
425+ this._pendingResultBatches.push(batch);
426+ batch.queueBatchId = this._queue.enqueue(messages, function () {
427+ self._completePendingResultBatch(batch);
428+ });
429+ return batch;
291430 };
292431
293-Companion.prototype._dispatchRequest = function (request) {
432+Companion.prototype._dispatchRequest = function (request, favorite) {
294433 var cached = this._cachedResult(request);
295- var cachedMessages;
296- var fixtureResult;
297- var favorite;
298- var generation;
434+ var flight;
299435 var age;
300436 var refreshRequired;
301437 if (cached !== null) {
302438 this._metrics.cacheHits += 1;
303439 age = this._clock.now() - Math.min(cached.result.fetchedAt * 1000, cached.storedAt);
304440 refreshRequired = !(age >= 0 && age < contracts.CACHE_FRESH_SECONDS * 1000);
305- cachedMessages = codec.encodeResult(cached.result);
306- if (refreshRequired) cachedMessages.push(codec.encodeRequest(request));
307- this._queue.enqueue(cachedMessages);
441+ this._enqueueVisibleResult(cached.result, refreshRequired ? request : null, request);
308442 if (!refreshRequired) return;
309443 } else {
310444 this._metrics.cacheMisses += 1;
311445 }
312- if (this._fixtureEnabled && this._fixture.favorite.id === request.favoriteId) {
313- fixtureResult = contracts.copyDepartureResult(this._fixture.result, request.requestId);
314- this._queue.enqueue(codec.encodeResult(fixtureResult));
315- return;
316- }
317446 if (this._configuration.primApiKey === null) {
318447 this._sendError(request, "API_KEY_REQUIRED");
319448 return;
@@ -323,23 +452,44 @@ Companion.prototype._dispatchRequest = function (request) {
323452 this._sendError(request, "API_KEY_INVALID");
324453 return;
325454 }
326- favorite = this._findFavorite(request.favoriteId);
327- if (favorite === null) {
328- this._sendError(request, "INVALID_SERVICE");
455+ flight = this._inFlightByFavorite[request.favoriteId];
456+ if (flight && flight.generation === this._lifecycleGeneration) {
457+ flight.latestRequest = request;
329458 return;
330459 }
331- generation = this._beginRequest(request.favoriteId);
332- this._fetch(request, favorite, generation);
460+ if (flight) delete this._inFlightByFavorite[request.favoriteId];
461+ this._fetch(request, favorite);
333462 };
334463
335-Companion.prototype._fetch = function (request, favorite, generation) {
464+Companion.prototype._fetch = function (request, favorite) {
336465 var self = this;
466+ var generation = this._lifecycleGeneration;
467+ var launchRequest = Object.freeze({
468+ requestId: request.requestId,
469+ favoriteId: request.favoriteId,
470+ serviceId: favorite.serviceId,
471+ trigger: request.trigger
472+ });
337473 var xhr;
474+ var flight;
475+ var failedRequest;
338476 var settled = false;
339477 var key = null;
340478 var startedAt = this._clock.now();
341479 if (this._backendUrl.indexOf("https://") !== 0) {
342- this._sendError(request, "SOURCE_UNAVAILABLE");
480+ if (request.transportEligible !== false
481+ && this._isCurrentWatchRequest(request)) {
482+ this._sendError(request, "SOURCE_UNAVAILABLE");
483+ }
484+ return;
485+ }
486+ key = this._configuration.primApiKey;
487+ if (!contracts.isPersonalApiKey(key)) {
488+ key = null;
489+ if (request.transportEligible !== false
490+ && this._isCurrentWatchRequest(request)) {
491+ this._sendError(request, "API_KEY_REQUIRED");
492+ }
343493 return;
344494 }
345495
@@ -348,58 +498,62 @@ Companion.prototype._fetch = function (request, favorite, generation) {
348498 var latency;
349499 if (settled) return;
350500 settled = true;
501+ if (generation !== self._lifecycleGeneration
502+ || self._inFlightByFavorite[launchRequest.favoriteId] !== flight) return;
503+ delete self._inFlightByFavorite[launchRequest.favoriteId];
351504 latency = Math.max(0, self._clock.now() - startedAt);
352505 self._metrics.lastLatencyMs = latency;
353506 self._metrics.totalLatencyMs += latency;
354- if (self._inFlightRequests[generation] === xhr) {
355- delete self._inFlightRequests[generation];
356- }
357- if (!self._isLatestRequest(request.favoriteId, generation)) return;
358507 callback();
359508 };
360509 }
361510
362511 try {
363512 xhr = new this._XHR();
513+ flight = {
514+ generation: generation,
515+ xhr: xhr,
516+ launchRequest: launchRequest,
517+ latestRequest: request
518+ };
519+ this._inFlightByFavorite[launchRequest.favoriteId] = flight;
364520 xhr.open("POST", this._backendUrl, true);
365521 xhr.timeout = contracts.LIMITS.httpTimeoutMs;
366522 xhr.setRequestHeader("Content-Type", "application/json");
367523 xhr.setRequestHeader("Accept", "application/json");
368- xhr.onload = once(function () { self._handleResponse(request, xhr); });
524+ xhr.onload = once(function () { self._handleResponse(flight, xhr); });
369525 xhr.onerror = once(function () {
370526 self._metrics.failures += 1;
371- self._sendError(request, "SOURCE_UNAVAILABLE");
527+ if (flight.latestRequest.transportEligible !== false
528+ && self._isCurrentWatchRequest(flight.latestRequest)) {
529+ self._sendError(flight.latestRequest, "SOURCE_UNAVAILABLE");
530+ }
372531 });
373532 xhr.ontimeout = xhr.onerror;
374533 xhr.onabort = xhr.onerror;
375- if (!this._isLatestRequest(request.favoriteId, generation)) return;
376- key = this._configuration.primApiKey;
377- if (!contracts.isPersonalApiKey(key)) {
378- key = null;
379- this._sendError(request, "API_KEY_REQUIRED");
380- return;
381- }
382534 xhr.setRequestHeader("Authorization", "Bearer " + key);
383535 this._metrics.requests += 1;
384- this._inFlightRequests[generation] = xhr;
385536 xhr.send(JSON.stringify({
386537 schemaVersion: contracts.SCHEMA_VERSION,
387- requestId: request.requestId,
388- favoriteId: request.favoriteId,
389- serviceId: favorite.serviceId
538+ requestId: launchRequest.requestId,
539+ favoriteId: launchRequest.favoriteId,
540+ serviceId: launchRequest.serviceId
390541 }));
391542 key = null;
392543 } catch (ignored) {
544+ failedRequest = request;
393545 key = null;
394- if (this._inFlightRequests[generation] === xhr) {
395- delete this._inFlightRequests[generation];
546+ if (settled || generation !== this._lifecycleGeneration) return;
547+ if (flight) {
548+ if (this._inFlightByFavorite[launchRequest.favoriteId] !== flight) return;
549+ delete this._inFlightByFavorite[launchRequest.favoriteId];
550+ failedRequest = flight.latestRequest;
396551 }
397- if (!settled) {
398- settled = true;
399- this._metrics.failures += 1;
400- if (this._isLatestRequest(request.favoriteId, generation)) {
401- this._sendError(request, "SOURCE_UNAVAILABLE");
402- }
552+ settled = true;
553+ this._metrics.failures += 1;
554+ if (failedRequest.transportEligible !== false
555+ && this._isCurrentWatchRequest(failedRequest)) {
556+ this._sendError(failedRequest, "SOURCE_UNAVAILABLE");
403557 }
404558 }
405559 };
@@ -415,48 +569,63 @@ Companion.prototype._responseJson = function (xhr) {
415569 }
416570 };
417571
418-Companion.prototype._handleResponse = function (request, xhr) {
572+Companion.prototype._handleResponse = function (flight, xhr) {
573+ var launchRequest = flight.launchRequest;
574+ var latestRequest = flight.latestRequest;
575+ var current = latestRequest.transportEligible !== false
576+ && this._isCurrentWatchRequest(latestRequest);
419577 var body = this._responseJson(xhr);
420578 var status = Number(xhr.status);
579+ var result;
421580 var error;
422581 if (status >= 200 && status < 300) {
423582 if (!contracts.isDepartureResult(body)
424- || body.requestId !== request.requestId
425- || body.favoriteId !== request.favoriteId) {
583+ || body.requestId !== launchRequest.requestId
584+ || body.favoriteId !== launchRequest.favoriteId) {
426585 this._metrics.failures += 1;
427- this._sendError(request, "INVALID_RESPONSE");
586+ if (current) this._sendError(latestRequest, "INVALID_RESPONSE");
428587 return;
429588 }
430589 this._metrics.successes += 1;
431590 this._cacheResult(body);
432- this._queue.enqueue(codec.encodeResult(body));
591+ if (!current) return;
592+ result = contracts.copyDepartureResult(body, latestRequest.requestId);
593+ this._enqueueVisibleResult(result, null, latestRequest);
433594 return;
434595 }
435596
436597 this._metrics.failures += 1;
598+ if (!current) return;
437599 if (status === 401 || status === 403) {
438- this._sendError(request, "API_KEY_INVALID");
600+ this._sendError(latestRequest, "API_KEY_INVALID");
439601 return;
440602 }
441603 if (body !== null && contracts.isErrorResult(body)) {
442- if (body.requestId !== request.requestId
443- || (typeof body.favoriteId !== "undefined" && body.favoriteId !== request.favoriteId)) {
444- this._sendError(request, "INVALID_RESPONSE");
604+ if (body.requestId !== launchRequest.requestId
605+ || (typeof body.favoriteId !== "undefined"
606+ && body.favoriteId !== launchRequest.favoriteId)) {
607+ this._sendError(latestRequest, "INVALID_RESPONSE");
445608 return;
446609 }
447610 error = {
448611 schemaVersion: contracts.SCHEMA_VERSION,
449- requestId: request.requestId,
450- favoriteId: request.favoriteId,
612+ requestId: latestRequest.requestId,
613+ favoriteId: latestRequest.favoriteId,
451614 code: body.code,
452615 occurredAt: body.occurredAt
453616 };
454- if (typeof body.retryAfterSeconds !== "undefined") error.retryAfterSeconds = body.retryAfterSeconds;
617+ if (typeof body.retryAfterSeconds !== "undefined") {
618+ error.retryAfterSeconds = body.retryAfterSeconds;
619+ }
455620 this._sendErrorResult(error);
456621 return;
457622 }
458623
459- this._sendError(request, this._httpErrorCode(status), this._retryAfter(xhr));
624+ this._sendError(
625+ latestRequest,
626+ this._httpErrorCode(status),
627+ this._retryAfter(xhr)
628+ );
460629 };
461630
462631 Companion.prototype._httpErrorCode = function (status) {
@@ -38,16 +38,28 @@ function normalizeIncomingPayload(payload) {
38 }38 }
39 return aliasCount > 0 ? normalized : null;39 return aliasCount > 0 ? normalized : null;
40 }40 }
41-41+function visibleResultSignature(result) {
42-function isFixture(value) {42+ return JSON.stringify({
43- return contracts.isObject(value)43+ favoriteId: result.favoriteId,
44- && contracts.hasOnlyKeys(value, ["favorite", "result"])44+ fetchedAt: result.fetchedAt,
45- && contracts.isFavorite(value.favorite)45+ freshness: result.freshness,
46- && contracts.isDepartureResult(value.result)46+ departures: result.departures.slice(0, 3).map(function (departure) {
47- && value.favorite.id === value.result.favoriteId;47+ var visible = {
48+ expectedAt: departure.expectedAt,
49+ status: departure.status
50+ };
51+ if (hasOwn.call(departure, "nextIntervalMinutes")) {
52+ visible.nextIntervalMinutes = departure.nextIntervalMinutes;
53+ }
54+ return visible;
55+ })
56+ });
48 }57 }
49 58
59+
60+
50 function Companion(options) {61 function Companion(options) {
62+ var self = this;
51 options = options || {};63 options = options || {};
52 requiredAdapter(options.Pebble && typeof options.Pebble.addEventListener === "function", "Pebble adapter is required");64 requiredAdapter(options.Pebble && typeof options.Pebble.addEventListener === "function", "Pebble adapter is required");
53 requiredAdapter(options.storage65 requiredAdapter(options.storage
@@ -58,18 +70,15 @@ function Companion(options) {
58 requiredAdapter(options.clock70 requiredAdapter(options.clock
59 && typeof options.clock.now === "function", "clock adapter is required");71 && typeof options.clock.now === "function", "clock adapter is required");
60 requiredAdapter(typeof options.defer === "function", "defer adapter is required");72 requiredAdapter(typeof options.defer === "function", "defer adapter is required");
61- if (typeof options.fixture !== "undefined" && !isFixture(options.fixture)) {73+ requiredAdapter(typeof options.readyDefer === "function", "readyDefer adapter is required");
62- throw new TypeError("fixture does not match the frozen contracts");
63- }
64 74
65 this._Pebble = options.Pebble;75 this._Pebble = options.Pebble;
66 this._storage = options.storage;76 this._storage = options.storage;
67 this._XHR = options.XHR;77 this._XHR = options.XHR;
68 this._clock = options.clock;78 this._clock = options.clock;
79+ this._readyDefer = options.readyDefer;
69 this._backendUrl = typeof options.backendUrl === "string" ? options.backendUrl : "";80 this._backendUrl = typeof options.backendUrl === "string" ? options.backendUrl : "";
70 this._configurationUrl = typeof options.configurationUrl === "string" ? options.configurationUrl : "";81 this._configurationUrl = typeof options.configurationUrl === "string" ? options.configurationUrl : "";
71- this._fixture = options.fixture || null;
72- this._fixtureEnabled = false;
73 this._configuration = configuration.loadConfiguration(this._storage);82 this._configuration = configuration.loadConfiguration(this._storage);
74 this._configurationValid = this._configuration !== null;83 this._configurationValid = this._configuration !== null;
75 this._credentialBlocked = this._configurationValid84 this._credentialBlocked = this._configurationValid
@@ -86,12 +95,18 @@ function Companion(options) {
86 totalLatencyMs: 0,95 totalLatencyMs: 0,
87 lastLatencyMs: 096 lastLatencyMs: 0
88 };97 };
89- this._requestGenerations = Object.create(null);98+ this._lifecycleGeneration = 0;
90- this._inFlightRequests = Object.create(null);99+ this._inFlightByFavorite = Object.create(null);
91- this._requestGeneration = 0;100+ this._latestWatchFavoriteId = null;
101+ this._latestWatchRequestId = null;
102+ this._presentationGeneration = 0;
103+ this._pendingResultBatches = [];
104+ this._visibleResultSignature = null;
92 this._sequence = 0;105 this._sequence = 0;
93 this._started = false;106 this._started = false;
94- this._queue = new MessageQueue(this._Pebble, options.defer);107+ this._queue = new MessageQueue(this._Pebble, options.defer, function () {
108+ self._failPendingResultBatches();
109+ });
95 this._handlers = null;110 this._handlers = null;
96 this._refreshWatchState();111 this._refreshWatchState();
97 }112 }
@@ -114,7 +129,6 @@ Companion.prototype.start = function () {
114 129
115 Companion.prototype.stop = function () {130 Companion.prototype.stop = function () {
116 var self = this;131 var self = this;
117- var inFlightRequests;
118 if (!this._started) return;132 if (!this._started) return;
119 if (typeof this._Pebble.removeEventListener === "function") {133 if (typeof this._Pebble.removeEventListener === "function") {
120 Object.keys(this._handlers).forEach(function (eventName) {134 Object.keys(this._handlers).forEach(function (eventName) {
@@ -122,26 +136,80 @@ Companion.prototype.stop = function () {
122 });136 });
123 }137 }
124 this._started = false;138 this._started = false;
125- this._requestGenerations = Object.create(null);139+ this._invalidateLifecycle();
126- inFlightRequests = this._inFlightRequests;140+};
127- this._inFlightRequests = Object.create(null);141+
142+Companion.prototype._refreshWatchState = function () {
143+ this._watchFavorites = this._configuration.favorites.map(contracts.copyFavorite);
144+};
145+Companion.prototype._resetPresentation = function () {
146+ this._presentationGeneration += 1;
147+ this._pendingResultBatches.forEach(function (batch) {
148+ batch.superseded = true;
149+ });
150+ this._visibleResultSignature = null;
151+};
152+
153+Companion.prototype._setLatestWatchRequest = function (request) {
154+ var sameFavorite = this._latestWatchFavoriteId === request.favoriteId;
155+ if (sameFavorite && this._latestWatchRequestId === request.requestId) return;
156+ this._latestWatchFavoriteId = request.favoriteId;
157+ this._latestWatchRequestId = request.requestId;
158+ if (!sameFavorite) {
159+ this._resetPresentation();
160+ return;
161+ }
162+ this._pendingResultBatches.forEach(function (batch) {
163+ if (batch.favoriteId === request.favoriteId
164+ && batch.requestId !== request.requestId) {
165+ batch.superseded = true;
166+ }
167+ });
168+};
169+
170+Companion.prototype._removePendingResultBatch = function (batch) {
171+ var index = this._pendingResultBatches.indexOf(batch);
172+ if (index !== -1) this._pendingResultBatches.splice(index, 1);
173+};
174+
175+Companion.prototype._completePendingResultBatch = function (batch) {
176+ this._removePendingResultBatch(batch);
177+ if (batch.failed
178+ || batch.superseded
179+ || batch.generation !== this._presentationGeneration
180+ || !this._isCurrentWatchRequest(batch.request)) return;
181+ this._visibleResultSignature = batch.signature;
182+};
183+
184+Companion.prototype._failPendingResultBatches = function () {
185+ var batches = this._pendingResultBatches;
186+ this._pendingResultBatches = [];
187+ batches.forEach(function (batch) {
188+ batch.failed = true;
189+ if (batch.mirrored) batch.request.transportEligible = false;
190+ });
191+};
192+
193+Companion.prototype._invalidateLifecycle = function () {
194+ var inFlight = this._inFlightByFavorite;
195+ this._lifecycleGeneration += 1;
196+ this._inFlightByFavorite = Object.create(null);
197+ this._latestWatchFavoriteId = null;
198+ this._latestWatchRequestId = null;
128 this._queue.clear();199 this._queue.clear();
129- Object.keys(inFlightRequests).forEach(function (generation) {200+ this._pendingResultBatches = [];
130- var xhr = inFlightRequests[generation];201+ this._resetPresentation();
131- if (!xhr || typeof xhr.abort !== "function") return;202+ Object.keys(inFlight).forEach(function (favoriteId) {
203+ var flight = inFlight[favoriteId];
204+ if (!flight.xhr || typeof flight.xhr.abort !== "function") return;
132 try {205 try {
133- xhr.abort();206+ flight.xhr.abort();
134 } catch (ignored) {207 } catch (ignored) {
135- // Generation invalidation remains authoritative if abort is unsupported or races.208+ // Lifecycle invalidation remains authoritative if abort is unsupported or races.
136 }209 }
137 });210 });
138 };211 };
139 212
140-Companion.prototype._refreshWatchState = function () {
141- var configured = this._configuration.favorites.map(contracts.copyFavorite);
142- this._fixtureEnabled = this._fixture !== null && configured.length === 0;
143- this._watchFavorites = this._fixtureEnabled ? [contracts.copyFavorite(this._fixture.favorite)] : configured;
144-};
145 213
146 Companion.prototype._nextSequenceId = function () {214 Companion.prototype._nextSequenceId = function () {
147 this._sequence += 1;215 this._sequence += 1;
@@ -150,17 +218,16 @@ Companion.prototype._nextSequenceId = function () {
150 };218 };
151 219
152 Companion.prototype._sendConfiguration = function () {220 Companion.prototype._sendConfiguration = function () {
153- var keyStatus = this._fixtureEnabled221+ var keyStatus = this._credentialBlocked
154- ? contracts.KEY_STATUS.CONFIGURED222+ ? contracts.KEY_STATUS.INVALID
155- : (this._credentialBlocked223+ : this._configuration.keyStatus;
156- ? contracts.KEY_STATUS.INVALID
157- : this._configuration.keyStatus);
158 if (!this._configurationValid224 if (!this._configurationValid
159 || !configuration.areFavoritesSecretFree(225 || !configuration.areFavoritesSecretFree(
160 this._watchFavorites,226 this._watchFavorites,
161 this._configuration.primApiKey,227 this._configuration.primApiKey,
162 null228 null
163 )) return;229 )) return;
230+ this._resetPresentation();
164 this._queue.enqueue(codec.encodeConfiguration(231 this._queue.enqueue(codec.encodeConfiguration(
165 this._nextSequenceId(),232 this._nextSequenceId(),
166 this._watchFavorites,233 this._watchFavorites,
@@ -170,6 +237,15 @@ Companion.prototype._sendConfiguration = function () {
170 };237 };
171 238
172 Companion.prototype._onReady = function () {239 Companion.prototype._onReady = function () {
240+ var self = this;
241+ var generation = this._lifecycleGeneration;
242+ this._readyDefer(function () {
243+ if (!self._started || self._lifecycleGeneration !== generation) return;
244+ self._synchronizeReady();
245+ });
246+};
247+
248+Companion.prototype._synchronizeReady = function () {
173 var wasBlocked = this._credentialBlocked;249 var wasBlocked = this._credentialBlocked;
174 var loaded = configuration.loadConfiguration(this._storage);250 var loaded = configuration.loadConfiguration(this._storage);
175 if (loaded === null) {251 if (loaded === null) {
@@ -234,9 +310,9 @@ Companion.prototype._onWebviewClosed = function (event) {
234 if (JSON.stringify(pruned) !== JSON.stringify(this._results)) {310 if (JSON.stringify(pruned) !== JSON.stringify(this._results)) {
235 configuration.saveResults(this._storage, pruned);311 configuration.saveResults(this._storage, pruned);
236 }312 }
313+ this._invalidateLifecycle();
237 this._configuration = next;314 this._configuration = next;
238 this._results = pruned;315 this._results = pruned;
239- this._requestGenerations = Object.create(null);
240 this._credentialBlocked = next.primApiKey !== null316 this._credentialBlocked = next.primApiKey !== null
241 && next.keyStatus === contracts.KEY_STATUS.INVALID;317 && next.keyStatus === contracts.KEY_STATUS.INVALID;
242 this._refreshWatchState();318 this._refreshWatchState();
@@ -244,15 +320,25 @@ Companion.prototype._onWebviewClosed = function (event) {
244 };320 };
245 321
246 Companion.prototype._onAppMessage = function (event) {322 Companion.prototype._onAppMessage = function (event) {
323+ var favorite;
324+ var decoded;
247 var request;325 var request;
248 if (!this._configurationValid) return;326 if (!this._configurationValid) return;
249- request = codec.decodeRequest(normalizeIncomingPayload(event && event.payload));327+ decoded = codec.decodeRequest(normalizeIncomingPayload(event && event.payload));
250- if (request === null) return;328+ if (decoded === null) return;
251- if (this._findFavorite(request.favoriteId) === null) {329+ request = {
330+ requestId: decoded.requestId,
331+ favoriteId: decoded.favoriteId,
332+ trigger: decoded.trigger,
333+ transportEligible: true
334+ };
335+ this._setLatestWatchRequest(request);
336+ favorite = this._findFavorite(request.favoriteId);
337+ if (favorite === null) {
252 this._sendError(request, "INVALID_SERVICE");338 this._sendError(request, "INVALID_SERVICE");
253 return;339 return;
254 }340 }
255- this._dispatchRequest(request);341+ this._dispatchRequest(request, favorite);
256 };342 };
257 343
258 Companion.prototype._findFavorite = function (favoriteId) {344 Companion.prototype._findFavorite = function (favoriteId) {
@@ -280,40 +366,83 @@ Companion.prototype._cachedResult = function (request) {
280 return configuration.findResult(this._results, request.favoriteId, request.requestId);366 return configuration.findResult(this._results, request.favoriteId, request.requestId);
281 };367 };
282 368
283-Companion.prototype._beginRequest = function (favoriteId) {369+Companion.prototype._isCurrentWatchRequest = function (request) {
284- this._requestGeneration += 1;370+ return this._latestWatchFavoriteId === request.favoriteId
285- this._requestGenerations[favoriteId] = this._requestGeneration;371+ && this._latestWatchRequestId === request.requestId;
286- return this._requestGeneration;
287 };372 };
288 373
289-Companion.prototype._isLatestRequest = function (favoriteId, generation) {374+Companion.prototype._enqueueVisibleResult = function (result, mirroredRequest, request) {
290- return this._requestGenerations[favoriteId] === generation;375+ var self = this;
376+ var generation = this._presentationGeneration;
377+ var signature = visibleResultSignature(result);
378+ var mirrored = mirroredRequest !== null;
379+ var visible = this._visibleResultSignature;
380+ var messages;
381+ var batch;
382+ var index;
383+ for (index = 0; index < this._pendingResultBatches.length; index += 1) {
384+ batch = this._pendingResultBatches[index];
385+ if (!batch.failed
386+ && !batch.superseded
387+ && batch.generation === generation
388+ && batch.favoriteId === result.favoriteId
389+ && batch.requestId === result.requestId
390+ && batch.signature === signature
391+ && batch.mirrored === mirrored) {
392+ batch.request = request;
393+ return null;
394+ }
395+ }
396+ if (visible === signature) return null;
397+
398+ messages = codec.encodeResult(result);
399+ if (mirrored) messages.push(codec.encodeRequest(mirroredRequest));
400+ for (index = this._pendingResultBatches.length - 1; index >= 0; index -= 1) {
401+ batch = this._pendingResultBatches[index];
402+ if (batch.failed
403+ || batch.generation !== generation
404+ || batch.favoriteId !== result.favoriteId
405+ || batch.signature !== signature
406+ || !this._queue.replacePending(batch.queueBatchId, messages)) continue;
407+ batch.requestId = result.requestId;
408+ batch.request = request;
409+ batch.mirrored = mirrored;
410+ batch.superseded = false;
411+ return batch;
412+ }
413+
414+ batch = {
415+ generation: generation,
416+ signature: signature,
417+ requestId: result.requestId,
418+ favoriteId: result.favoriteId,
419+ request: request,
420+ mirrored: mirrored,
421+ failed: false,
422+ superseded: false,
423+ queueBatchId: null
424+ };
425+ this._pendingResultBatches.push(batch);
426+ batch.queueBatchId = this._queue.enqueue(messages, function () {
427+ self._completePendingResultBatch(batch);
428+ });
429+ return batch;
291 };430 };
292 431
293-Companion.prototype._dispatchRequest = function (request) {432+Companion.prototype._dispatchRequest = function (request, favorite) {
294 var cached = this._cachedResult(request);433 var cached = this._cachedResult(request);
295- var cachedMessages;434+ var flight;
296- var fixtureResult;
297- var favorite;
298- var generation;
299 var age;435 var age;
300 var refreshRequired;436 var refreshRequired;
301 if (cached !== null) {437 if (cached !== null) {
302 this._metrics.cacheHits += 1;438 this._metrics.cacheHits += 1;
303 age = this._clock.now() - Math.min(cached.result.fetchedAt * 1000, cached.storedAt);439 age = this._clock.now() - Math.min(cached.result.fetchedAt * 1000, cached.storedAt);
304 refreshRequired = !(age >= 0 && age < contracts.CACHE_FRESH_SECONDS * 1000);440 refreshRequired = !(age >= 0 && age < contracts.CACHE_FRESH_SECONDS * 1000);
305- cachedMessages = codec.encodeResult(cached.result);441+ this._enqueueVisibleResult(cached.result, refreshRequired ? request : null, request);
306- if (refreshRequired) cachedMessages.push(codec.encodeRequest(request));
307- this._queue.enqueue(cachedMessages);
308 if (!refreshRequired) return;442 if (!refreshRequired) return;
309 } else {443 } else {
310 this._metrics.cacheMisses += 1;444 this._metrics.cacheMisses += 1;
311 }445 }
312- if (this._fixtureEnabled && this._fixture.favorite.id === request.favoriteId) {
313- fixtureResult = contracts.copyDepartureResult(this._fixture.result, request.requestId);
314- this._queue.enqueue(codec.encodeResult(fixtureResult));
315- return;
316- }
317 if (this._configuration.primApiKey === null) {446 if (this._configuration.primApiKey === null) {
318 this._sendError(request, "API_KEY_REQUIRED");447 this._sendError(request, "API_KEY_REQUIRED");
319 return;448 return;
@@ -323,23 +452,44 @@ Companion.prototype._dispatchRequest = function (request) {
323 this._sendError(request, "API_KEY_INVALID");452 this._sendError(request, "API_KEY_INVALID");
324 return;453 return;
325 }454 }
326- favorite = this._findFavorite(request.favoriteId);455+ flight = this._inFlightByFavorite[request.favoriteId];
327- if (favorite === null) {456+ if (flight && flight.generation === this._lifecycleGeneration) {
328- this._sendError(request, "INVALID_SERVICE");457+ flight.latestRequest = request;
329 return;458 return;
330 }459 }
331- generation = this._beginRequest(request.favoriteId);460+ if (flight) delete this._inFlightByFavorite[request.favoriteId];
332- this._fetch(request, favorite, generation);461+ this._fetch(request, favorite);
333 };462 };
334 463
335-Companion.prototype._fetch = function (request, favorite, generation) {464+Companion.prototype._fetch = function (request, favorite) {
336 var self = this;465 var self = this;
466+ var generation = this._lifecycleGeneration;
467+ var launchRequest = Object.freeze({
468+ requestId: request.requestId,
469+ favoriteId: request.favoriteId,
470+ serviceId: favorite.serviceId,
471+ trigger: request.trigger
472+ });
337 var xhr;473 var xhr;
474+ var flight;
475+ var failedRequest;
338 var settled = false;476 var settled = false;
339 var key = null;477 var key = null;
340 var startedAt = this._clock.now();478 var startedAt = this._clock.now();
341 if (this._backendUrl.indexOf("https://") !== 0) {479 if (this._backendUrl.indexOf("https://") !== 0) {
342- this._sendError(request, "SOURCE_UNAVAILABLE");480+ if (request.transportEligible !== false
481+ && this._isCurrentWatchRequest(request)) {
482+ this._sendError(request, "SOURCE_UNAVAILABLE");
483+ }
484+ return;
485+ }
486+ key = this._configuration.primApiKey;
487+ if (!contracts.isPersonalApiKey(key)) {
488+ key = null;
489+ if (request.transportEligible !== false
490+ && this._isCurrentWatchRequest(request)) {
491+ this._sendError(request, "API_KEY_REQUIRED");
492+ }
343 return;493 return;
344 }494 }
345 495
@@ -348,58 +498,62 @@ Companion.prototype._fetch = function (request, favorite, generation) {
348 var latency;498 var latency;
349 if (settled) return;499 if (settled) return;
350 settled = true;500 settled = true;
501+ if (generation !== self._lifecycleGeneration
502+ || self._inFlightByFavorite[launchRequest.favoriteId] !== flight) return;
503+ delete self._inFlightByFavorite[launchRequest.favoriteId];
351 latency = Math.max(0, self._clock.now() - startedAt);504 latency = Math.max(0, self._clock.now() - startedAt);
352 self._metrics.lastLatencyMs = latency;505 self._metrics.lastLatencyMs = latency;
353 self._metrics.totalLatencyMs += latency;506 self._metrics.totalLatencyMs += latency;
354- if (self._inFlightRequests[generation] === xhr) {
355- delete self._inFlightRequests[generation];
356- }
357- if (!self._isLatestRequest(request.favoriteId, generation)) return;
358 callback();507 callback();
359 };508 };
360 }509 }
361 510
362 try {511 try {
363 xhr = new this._XHR();512 xhr = new this._XHR();
513+ flight = {
514+ generation: generation,
515+ xhr: xhr,
516+ launchRequest: launchRequest,
517+ latestRequest: request
518+ };
519+ this._inFlightByFavorite[launchRequest.favoriteId] = flight;
364 xhr.open("POST", this._backendUrl, true);520 xhr.open("POST", this._backendUrl, true);
365 xhr.timeout = contracts.LIMITS.httpTimeoutMs;521 xhr.timeout = contracts.LIMITS.httpTimeoutMs;
366 xhr.setRequestHeader("Content-Type", "application/json");522 xhr.setRequestHeader("Content-Type", "application/json");
367 xhr.setRequestHeader("Accept", "application/json");523 xhr.setRequestHeader("Accept", "application/json");
368- xhr.onload = once(function () { self._handleResponse(request, xhr); });524+ xhr.onload = once(function () { self._handleResponse(flight, xhr); });
369 xhr.onerror = once(function () {525 xhr.onerror = once(function () {
370 self._metrics.failures += 1;526 self._metrics.failures += 1;
371- self._sendError(request, "SOURCE_UNAVAILABLE");527+ if (flight.latestRequest.transportEligible !== false
528+ && self._isCurrentWatchRequest(flight.latestRequest)) {
529+ self._sendError(flight.latestRequest, "SOURCE_UNAVAILABLE");
530+ }
372 });531 });
373 xhr.ontimeout = xhr.onerror;532 xhr.ontimeout = xhr.onerror;
374 xhr.onabort = xhr.onerror;533 xhr.onabort = xhr.onerror;
375- if (!this._isLatestRequest(request.favoriteId, generation)) return;
376- key = this._configuration.primApiKey;
377- if (!contracts.isPersonalApiKey(key)) {
378- key = null;
379- this._sendError(request, "API_KEY_REQUIRED");
380- return;
381- }
382 xhr.setRequestHeader("Authorization", "Bearer " + key);534 xhr.setRequestHeader("Authorization", "Bearer " + key);
383 this._metrics.requests += 1;535 this._metrics.requests += 1;
384- this._inFlightRequests[generation] = xhr;
385 xhr.send(JSON.stringify({536 xhr.send(JSON.stringify({
386 schemaVersion: contracts.SCHEMA_VERSION,537 schemaVersion: contracts.SCHEMA_VERSION,
387- requestId: request.requestId,538+ requestId: launchRequest.requestId,
388- favoriteId: request.favoriteId,539+ favoriteId: launchRequest.favoriteId,
389- serviceId: favorite.serviceId540+ serviceId: launchRequest.serviceId
390 }));541 }));
391 key = null;542 key = null;
392 } catch (ignored) {543 } catch (ignored) {
544+ failedRequest = request;
393 key = null;545 key = null;
394- if (this._inFlightRequests[generation] === xhr) {546+ if (settled || generation !== this._lifecycleGeneration) return;
395- delete this._inFlightRequests[generation];547+ if (flight) {
548+ if (this._inFlightByFavorite[launchRequest.favoriteId] !== flight) return;
549+ delete this._inFlightByFavorite[launchRequest.favoriteId];
550+ failedRequest = flight.latestRequest;
396 }551 }
397- if (!settled) {552+ settled = true;
398- settled = true;553+ this._metrics.failures += 1;
399- this._metrics.failures += 1;554+ if (failedRequest.transportEligible !== false
400- if (this._isLatestRequest(request.favoriteId, generation)) {555+ && this._isCurrentWatchRequest(failedRequest)) {
401- this._sendError(request, "SOURCE_UNAVAILABLE");556+ this._sendError(failedRequest, "SOURCE_UNAVAILABLE");
402- }
403 }557 }
404 }558 }
405 };559 };
@@ -415,48 +569,63 @@ Companion.prototype._responseJson = function (xhr) {
415 }569 }
416 };570 };
417 571
418-Companion.prototype._handleResponse = function (request, xhr) {572+Companion.prototype._handleResponse = function (flight, xhr) {
573+ var launchRequest = flight.launchRequest;
574+ var latestRequest = flight.latestRequest;
575+ var current = latestRequest.transportEligible !== false
576+ && this._isCurrentWatchRequest(latestRequest);
419 var body = this._responseJson(xhr);577 var body = this._responseJson(xhr);
420 var status = Number(xhr.status);578 var status = Number(xhr.status);
579+ var result;
421 var error;580 var error;
422 if (status >= 200 && status < 300) {581 if (status >= 200 && status < 300) {
423 if (!contracts.isDepartureResult(body)582 if (!contracts.isDepartureResult(body)
424- || body.requestId !== request.requestId583+ || body.requestId !== launchRequest.requestId
425- || body.favoriteId !== request.favoriteId) {584+ || body.favoriteId !== launchRequest.favoriteId) {
426 this._metrics.failures += 1;585 this._metrics.failures += 1;
427- this._sendError(request, "INVALID_RESPONSE");586+ if (current) this._sendError(latestRequest, "INVALID_RESPONSE");
428 return;587 return;
429 }588 }
430 this._metrics.successes += 1;589 this._metrics.successes += 1;
431 this._cacheResult(body);590 this._cacheResult(body);
432- this._queue.enqueue(codec.encodeResult(body));591+ if (!current) return;
592+ result = contracts.copyDepartureResult(body, latestRequest.requestId);
593+ this._enqueueVisibleResult(result, null, latestRequest);
433 return;594 return;
434 }595 }
435 596
436 this._metrics.failures += 1;597 this._metrics.failures += 1;
598+ if (!current) return;
437 if (status === 401 || status === 403) {599 if (status === 401 || status === 403) {
438- this._sendError(request, "API_KEY_INVALID");600+ this._sendError(latestRequest, "API_KEY_INVALID");
439 return;601 return;
440 }602 }
441 if (body !== null && contracts.isErrorResult(body)) {603 if (body !== null && contracts.isErrorResult(body)) {
442- if (body.requestId !== request.requestId604+ if (body.requestId !== launchRequest.requestId
443- || (typeof body.favoriteId !== "undefined" && body.favoriteId !== request.favoriteId)) {605+ || (typeof body.favoriteId !== "undefined"
444- this._sendError(request, "INVALID_RESPONSE");606+ && body.favoriteId !== launchRequest.favoriteId)) {
607+ this._sendError(latestRequest, "INVALID_RESPONSE");
445 return;608 return;
446 }609 }
447 error = {610 error = {
448 schemaVersion: contracts.SCHEMA_VERSION,611 schemaVersion: contracts.SCHEMA_VERSION,
449- requestId: request.requestId,612+ requestId: latestRequest.requestId,
450- favoriteId: request.favoriteId,613+ favoriteId: latestRequest.favoriteId,
451 code: body.code,614 code: body.code,
452 occurredAt: body.occurredAt615 occurredAt: body.occurredAt
453 };616 };
454- if (typeof body.retryAfterSeconds !== "undefined") error.retryAfterSeconds = body.retryAfterSeconds;617+ if (typeof body.retryAfterSeconds !== "undefined") {
618+ error.retryAfterSeconds = body.retryAfterSeconds;
619+ }
455 this._sendErrorResult(error);620 this._sendErrorResult(error);
456 return;621 return;
457 }622 }
458 623
459- this._sendError(request, this._httpErrorCode(status), this._retryAfter(xhr));624+ this._sendError(
625+ latestRequest,
626+ this._httpErrorCode(status),
627+ this._retryAfter(xhr)
628+ );
460 };629 };
461 630
462 Companion.prototype._httpErrorCode = function (status) {631 Companion.prototype._httpErrorCode = function (status) {
modified packages/companion/src/message-queue.js +57 -10
@@ -11,16 +11,42 @@ function MessageQueue(Pebble, defer, onFailure) {
1111 this._batches = [];
1212 this._active = null;
1313 this._sending = false;
14+ this._completing = false;
1415 this._deferredGeneration = null;
1516 this._generation = 0;
17+ this._nextBatchId = 1;
1618 }
1719
18-MessageQueue.prototype.enqueue = function (messages) {
20+MessageQueue.prototype.enqueue = function (messages, onComplete) {
21+ var batch;
1922 if (!Array.isArray(messages) || messages.length === 0) {
2023 throw new TypeError("A non-empty message sequence is required");
2124 }
22- this._batches.push(messages.slice());
25+ if (typeof onComplete !== "undefined" && typeof onComplete !== "function") {
26+ throw new TypeError("onComplete must be a function");
27+ }
28+ batch = {
29+ id: this._nextBatchId,
30+ messages: messages.slice(),
31+ onComplete: typeof onComplete === "function" ? onComplete : null
32+ };
33+ this._nextBatchId += 1;
34+ this._batches.push(batch);
2335 this._advance();
36+ return batch.id;
37+};
38+
39+MessageQueue.prototype.replacePending = function (batchId, messages) {
40+ var index;
41+ if (!Array.isArray(messages) || messages.length === 0) {
42+ throw new TypeError("A non-empty message sequence is required");
43+ }
44+ for (index = 0; index < this._batches.length; index += 1) {
45+ if (this._batches[index].id !== batchId) continue;
46+ this._batches[index].messages = messages.slice();
47+ return true;
48+ }
49+ return false;
2450 };
2551
2652 MessageQueue.prototype.clear = function () {
@@ -54,13 +80,16 @@ MessageQueue.prototype._scheduleAdvance = function (generation) {
5480
5581 MessageQueue.prototype._advance = function () {
5682 var self = this;
83+ var completed;
5784 var message;
5885 var generation;
59- if (this._sending || this._deferredGeneration !== null) return;
60- if (!this._active || this._active.length === 0) this._active = this._batches.shift() || null;
86+ if (this._sending || this._completing || this._deferredGeneration !== null) return;
87+ if (!this._active || this._active.messages.length === 0) {
88+ this._active = this._batches.shift() || null;
89+ }
6190 if (!this._active) return;
6291
63- message = this._active[0];
92+ message = this._active.messages[0];
6493 this._sending = true;
6594 generation = this._generation;
6695 try {
@@ -72,8 +101,26 @@ MessageQueue.prototype._advance = function () {
72101 }
73102 return;
74103 }
75- self._active.shift();
76- if (self._active.length === 0) self._active = null;
104+ self._active.messages.shift();
105+ if (self._active.messages.length === 0) {
106+ completed = self._active;
107+ self._active = null;
108+ if (completed.onComplete !== null) {
109+ self._completing = true;
110+ try {
111+ completed.onComplete();
112+ } catch (ignored) {
113+ // Completion metadata must not change acknowledged transport state.
114+ }
115+ self._completing = false;
116+ if (generation !== self._generation) {
117+ if (self._active !== null || self._batches.length > 0) {
118+ self._scheduleAdvance(self._generation);
119+ }
120+ return;
121+ }
122+ }
123+ }
77124 if (self._active !== null || self._batches.length > 0) {
78125 self._scheduleAdvance(generation);
79126 }
@@ -89,9 +136,9 @@ MessageQueue.prototype._advance = function () {
89136 self._onFailure("APP_MESSAGE_FAILED");
90137 });
91138 } catch (ignored) {
92- this._sending = false;
93- this.clear();
94- this._onFailure("APP_MESSAGE_FAILED");
139+ self._sending = false;
140+ self.clear();
141+ self._onFailure("APP_MESSAGE_FAILED");
95142 }
96143 };
97144
@@ -11,16 +11,42 @@ function MessageQueue(Pebble, defer, onFailure) {
11 this._batches = [];11 this._batches = [];
12 this._active = null;12 this._active = null;
13 this._sending = false;13 this._sending = false;
14+ this._completing = false;
14 this._deferredGeneration = null;15 this._deferredGeneration = null;
15 this._generation = 0;16 this._generation = 0;
17+ this._nextBatchId = 1;
16 }18 }
17 19
18-MessageQueue.prototype.enqueue = function (messages) {20+MessageQueue.prototype.enqueue = function (messages, onComplete) {
21+ var batch;
19 if (!Array.isArray(messages) || messages.length === 0) {22 if (!Array.isArray(messages) || messages.length === 0) {
20 throw new TypeError("A non-empty message sequence is required");23 throw new TypeError("A non-empty message sequence is required");
21 }24 }
22- this._batches.push(messages.slice());25+ if (typeof onComplete !== "undefined" && typeof onComplete !== "function") {
26+ throw new TypeError("onComplete must be a function");
27+ }
28+ batch = {
29+ id: this._nextBatchId,
30+ messages: messages.slice(),
31+ onComplete: typeof onComplete === "function" ? onComplete : null
32+ };
33+ this._nextBatchId += 1;
34+ this._batches.push(batch);
23 this._advance();35 this._advance();
36+ return batch.id;
37+};
38+
39+MessageQueue.prototype.replacePending = function (batchId, messages) {
40+ var index;
41+ if (!Array.isArray(messages) || messages.length === 0) {
42+ throw new TypeError("A non-empty message sequence is required");
43+ }
44+ for (index = 0; index < this._batches.length; index += 1) {
45+ if (this._batches[index].id !== batchId) continue;
46+ this._batches[index].messages = messages.slice();
47+ return true;
48+ }
49+ return false;
24 };50 };
25 51
26 MessageQueue.prototype.clear = function () {52 MessageQueue.prototype.clear = function () {
@@ -54,13 +80,16 @@ MessageQueue.prototype._scheduleAdvance = function (generation) {
54 80
55 MessageQueue.prototype._advance = function () {81 MessageQueue.prototype._advance = function () {
56 var self = this;82 var self = this;
83+ var completed;
57 var message;84 var message;
58 var generation;85 var generation;
59- if (this._sending || this._deferredGeneration !== null) return;86+ if (this._sending || this._completing || this._deferredGeneration !== null) return;
60- if (!this._active || this._active.length === 0) this._active = this._batches.shift() || null;87+ if (!this._active || this._active.messages.length === 0) {
88+ this._active = this._batches.shift() || null;
89+ }
61 if (!this._active) return;90 if (!this._active) return;
62 91
63- message = this._active[0];92+ message = this._active.messages[0];
64 this._sending = true;93 this._sending = true;
65 generation = this._generation;94 generation = this._generation;
66 try {95 try {
@@ -72,8 +101,26 @@ MessageQueue.prototype._advance = function () {
72 }101 }
73 return;102 return;
74 }103 }
75- self._active.shift();104+ self._active.messages.shift();
76- if (self._active.length === 0) self._active = null;105+ if (self._active.messages.length === 0) {
106+ completed = self._active;
107+ self._active = null;
108+ if (completed.onComplete !== null) {
109+ self._completing = true;
110+ try {
111+ completed.onComplete();
112+ } catch (ignored) {
113+ // Completion metadata must not change acknowledged transport state.
114+ }
115+ self._completing = false;
116+ if (generation !== self._generation) {
117+ if (self._active !== null || self._batches.length > 0) {
118+ self._scheduleAdvance(self._generation);
119+ }
120+ return;
121+ }
122+ }
123+ }
77 if (self._active !== null || self._batches.length > 0) {124 if (self._active !== null || self._batches.length > 0) {
78 self._scheduleAdvance(generation);125 self._scheduleAdvance(generation);
79 }126 }
@@ -89,9 +136,9 @@ MessageQueue.prototype._advance = function () {
89 self._onFailure("APP_MESSAGE_FAILED");136 self._onFailure("APP_MESSAGE_FAILED");
90 });137 });
91 } catch (ignored) {138 } catch (ignored) {
92- this._sending = false;139+ self._sending = false;
93- this.clear();140+ self.clear();
94- this._onFailure("APP_MESSAGE_FAILED");141+ self._onFailure("APP_MESSAGE_FAILED");
95 }142 }
96 };143 };
97 144
modified packages/companion/test/companion.test.js +547 -120
@@ -3,8 +3,6 @@
33 var test = require("node:test");
44 var assert = require("node:assert/strict");
55 var vm = require("node:vm");
6-var path = require("node:path");
7-var url = require("node:url");
86 var companion = require("../src");
97 var fixture = require("../../../fixtures/departures/foundation.json");
108 var fakes = require("./fakes");
@@ -14,6 +12,13 @@ var codec = companion.codec;
1412 var configuration = companion.configuration;
1513 var T = contracts.MESSAGE_TYPE;
1614 var TEST_KEY = "test-personal-prim-key";
15+var SECOND_FAVORITE = Object.freeze(Object.assign({}, fixture.favorite, {
16+ id: "foundation-work",
17+ serviceId: "opaque:foundation:service:2",
18+ displayName: "Commute work",
19+ destinationLabel: "Château de Vincennes",
20+ sortOrder: 1
21+}));
1722
1823 function harness(options) {
1924 var Pebble;
@@ -21,6 +26,7 @@ function harness(options) {
2126 var clock;
2227 var xhr;
2328 var defer;
29+ var readyDefer;
2430 var instance;
2531 options = options || {};
2632 Pebble = options.Pebble || new fakes.FakePebble(true);
@@ -28,30 +34,34 @@ function harness(options) {
2834 clock = options.clock || new fakes.FakeClock();
2935 xhr = fakes.createXHRFactory();
3036 defer = typeof options.defer === "function" ? options.defer : fakes.createDefer(true);
37+ readyDefer = typeof options.readyDefer === "function"
38+ ? options.readyDefer
39+ : fakes.createDefer(true);
3140 instance = companion.createCompanion({
3241 Pebble: Pebble,
3342 storage: storage,
34- XHR: xhr.XHR,
43+ XHR: options.XHR || xhr.XHR,
3544 clock: clock,
3645 defer: defer,
46+ readyDefer: readyDefer,
3747 backendUrl: "https://backend.example.test/departures",
38- configurationUrl: "https://config.example.test/index.html",
39- fixture: options.fixture
48+ configurationUrl: "https://config.example.test/index.html"
4049 });
4150 return {
4251 Pebble: Pebble,
4352 storage: storage,
4453 clock: clock,
4554 defer: defer,
55+ readyDefer: readyDefer,
4656 xhr: xhr,
4757 companion: instance
4858 };
4959 }
5060
51-function configurationPayload(action, key) {
61+function configurationPayload(action, key, favorites) {
5262 var update = {
5363 schemaVersion: contracts.SCHEMA_VERSION,
54- favorites: [fixture.favorite],
64+ favorites: favorites || [fixture.favorite],
5565 apiKeyUpdate: { schemaVersion: contracts.SCHEMA_VERSION, action: action }
5666 };
5767 if (action === "REPLACE") update.apiKeyUpdate.value = key;
@@ -64,11 +74,11 @@ function closeWith(target, payload) {
6474 });
6575 }
6676
67-function sendRequest(target, requestId, trigger) {
77+function sendRequest(target, requestId, trigger, favorite) {
6878 target.Pebble.emit("appmessage", {
6979 payload: codec.encodeRequest({
7080 requestId: requestId,
71- favoriteId: fixture.favorite.id,
81+ favoriteId: (favorite || fixture.favorite).id,
7282 trigger: typeof trigger === "number" ? trigger : contracts.REQUEST_TRIGGER.APP_OPEN
7383 })
7484 });
@@ -95,22 +105,46 @@ function messagesOfType(target, type) {
95105 });
96106 }
97107
98-function responseFor(requestId) {
108+function responseFor(requestId, favorite) {
99109 var result = contracts.copyDepartureResult(fixture.result, requestId);
110+ result.favoriteId = (favorite || fixture.favorite).id;
100111 return result;
101112 }
102113
103-async function watchProtocol() {
104- var protocolPath = path.resolve(__dirname, "../../watch/src/embeddedjs/protocol.js");
105- return import(url.pathToFileURL(protocolPath).href);
114+function configuredStorage(favorites, keyStatus) {
115+ var storage = new fakes.FakeStorage();
116+ configuration.saveConfiguration(storage, {
117+ schemaVersion: contracts.SCHEMA_VERSION,
118+ favorites: favorites || [fixture.favorite],
119+ primApiKey: TEST_KEY,
120+ keyStatus: typeof keyStatus === "number"
121+ ? keyStatus
122+ : contracts.KEY_STATUS.CONFIGURED
123+ });
124+ storage.writes.length = 0;
125+ return storage;
106126 }
107127
108-function watchMessage(message) {
109- return new Map(Object.keys(message).map(function (key) {
110- return [key, message[key]];
111- }));
128+function storeResult(storage, result, storedAt) {
129+ configuration.saveResults(storage, {
130+ schemaVersion: contracts.SCHEMA_VERSION,
131+ results: [{
132+ favoriteId: result.favoriteId,
133+ storedAt: storedAt,
134+ result: result
135+ }]
136+ });
137+ storage.writes.length = 0;
112138 }
113139
140+function drainQueue(target) {
141+ while (target.Pebble.pending.length > 0 || target.defer.pending.length > 0) {
142+ if (target.Pebble.pending.length > 0) target.Pebble.ack();
143+ else target.defer.runNext();
144+ }
145+}
146+
147+
114148 test("close fragment atomically stores one favorites-and-key record and exposes the key only to Authorization", function () {
115149 var target = harness();
116150 var stored;
@@ -268,11 +302,21 @@ test("untrusted webview responses are bounded and cannot persist a key inside fa
268302 });
269303
270304
271-test("configuration advances one event-loop turn at a time after immediate ACKs", function () {
305+test("ready settling is distinct from ACK backpressure deferrals", function () {
272306 var defer = fakes.createDefer(false);
273- var target = harness({ fixture: fixture, defer: defer });
307+ var readyDefer = fakes.createDefer(false);
308+ var target = harness({
309+ storage: configuredStorage(),
310+ defer: defer,
311+ readyDefer: readyDefer
312+ });
274313
275314 target.Pebble.emit("ready");
315+ assert.deepEqual(target.Pebble.sent, []);
316+ assert.equal(readyDefer.pending.length, 1);
317+ assert.equal(defer.pending.length, 0);
318+
319+ readyDefer.runNext();
276320 assert.deepEqual(target.Pebble.sent.map(function (message) { return message.MESSAGE_TYPE; }), [
277321 T.CONFIG_BEGIN
278322 ]);
@@ -294,16 +338,18 @@ test("configuration advances one event-loop turn at a time after immediate ACKs"
294338 assert.equal(defer.pending.length, 0);
295339 });
296340
297-test("stop invalidates a deferred configuration advance", function () {
298- var defer = fakes.createDefer(false);
299- var target = harness({ fixture: fixture, defer: defer });
341+test("stop invalidates a deferred ready synchronization", function () {
342+ var readyDefer = fakes.createDefer(false);
343+ var target = harness({
344+ storage: configuredStorage(),
345+ readyDefer: readyDefer
346+ });
300347
301348 target.Pebble.emit("ready");
349+ assert.deepEqual(target.Pebble.sent, []);
302350 target.companion.stop();
303- defer.runNext();
304- assert.deepEqual(target.Pebble.sent.map(function (message) { return message.MESSAGE_TYPE; }), [
305- T.CONFIG_BEGIN
306- ]);
351+ readyDefer.runNext();
352+ assert.deepEqual(target.Pebble.sent, []);
307353 });
308354
309355 test("stop aborts and invalidates a late request callback across restart", function () {
@@ -332,34 +378,23 @@ test("stop aborts and invalidates a late request callback across restart", funct
332378 assert.equal(resultBegins[resultBegins.length - 1].REQUEST_ID, "after-restart");
333379 });
334380
335-test("ready sends full config and APP_OPEN returns the fixture without XHR or product timers", function () {
336- var target = harness({ fixture: fixture });
337- var before;
338- var resultBegins;
381+test("ready keeps a missing-key configuration with no favorites unconfigured", function () {
382+ var target = harness();
339383
340384 target.Pebble.emit("ready");
341385 assert.deepEqual(target.Pebble.sent.map(function (message) { return message.MESSAGE_TYPE; }), [
342386 T.CONFIG_BEGIN,
343- T.FAVORITE,
344387 T.CONFIG_COMMIT
345388 ]);
346389 assert.equal(target.Pebble.sent[0].DISPLAY_NAME, "en");
347- assert.equal(target.Pebble.sent[0].KEY_STATUS, contracts.KEY_STATUS.CONFIGURED);
348- assert.equal(target.Pebble.sent.every(function (message) {
349- return Object.keys(message).every(function (key) { return !/^[0-9]+$/.test(key); });
350- }), true);
351-
352- before = target.Pebble.sent.length;
353- sendRequest(target, "app-open");
354- assert.equal(target.Pebble.sent.length, before + fixture.result.departures.length + 2);
355- resultBegins = messagesOfType(target, T.RESULT_BEGIN);
356- assert.equal(resultBegins[resultBegins.length - 1].REQUEST_ID, "app-open");
390+ assert.equal(target.Pebble.sent[0].KEY_STATUS, contracts.KEY_STATUS.MISSING);
391+ assert.equal(JSON.stringify(target.Pebble.sent).includes(fixture.favorite.id), false);
357392 assert.equal(target.xhr.instances.length, 0);
358393 assert.equal(target.clock.timers.length, 0);
359394 });
360395
361396 test("pypkjs dual request properties are canonicalized only at the event boundary", function () {
362- var target = harness({ fixture: fixture });
397+ var target = harness({ storage: configuredStorage() });
363398 var payload = dualRequestPayload("dual-app-open");
364399 var before;
365400 var resultBegins;
@@ -368,16 +403,18 @@ test("pypkjs dual request properties are canonicalized only at the event boundar
368403 before = target.Pebble.sent.length;
369404 assert.equal(codec.decodeRequest(payload), null);
370405 target.Pebble.emit("appmessage", { payload: payload });
406+ assert.equal(target.xhr.instances.length, 1);
407+ target.xhr.instances[0].respond(200, responseFor("dual-app-open"));
371408
372409 assert.equal(target.Pebble.sent.length, before + fixture.result.departures.length + 2);
373410 resultBegins = messagesOfType(target, T.RESULT_BEGIN);
374411 assert.equal(resultBegins[resultBegins.length - 1].REQUEST_ID, "dual-app-open");
375- assert.equal(target.xhr.instances.length, 0);
412+ assert.equal(target.xhr.instances.length, 1);
376413 assert.equal(target.clock.timers.length, 0);
377414 });
378415
379416 test("incoming event normalization rejects numeric-only, unknown, conflicting, extra, and internal properties", function () {
380- var target = harness({ fixture: fixture });
417+ var target = harness({ storage: configuredStorage() });
381418 var numericOnly = dualRequestPayload("numeric-only");
382419 var unknown = dualRequestPayload("unknown");
383420 var conflicting = dualRequestPayload("conflicting");
@@ -411,83 +448,144 @@ test("incoming event normalization rejects numeric-only, unknown, conflicting, e
411448 assert.equal(target.clock.timers.length, 0);
412449 });
413450
414-test("fresh results are rebound from cache for 60 seconds without a network timer", function () {
451+test("all three triggers send fresh cache with zero XHR and expired cache before one XHR", function () {
452+ Object.keys(contracts.REQUEST_TRIGGER).forEach(function (name, index) {
453+ var trigger = contracts.REQUEST_TRIGGER[name];
454+ var freshClock = new fakes.FakeClock();
455+ var freshStorage = configuredStorage();
456+ var freshResult = responseFor("stored-fresh-" + index);
457+ var freshTarget;
458+ var expiredClock = new fakes.FakeClock();
459+ var expiredStorage = configuredStorage();
460+ var expiredResult = responseFor("stored-expired-" + index);
461+ var expiredTarget;
462+ var sent;
463+
464+ storeResult(freshStorage, freshResult, freshClock.now());
465+ freshTarget = harness({ storage: freshStorage, clock: freshClock });
466+ freshTarget.Pebble.emit("ready");
467+ sent = freshTarget.Pebble.sent.length;
468+ sendRequest(freshTarget, "fresh-" + index, trigger);
469+ assert.equal(freshTarget.xhr.instances.length, 0, name);
470+ assert.deepEqual(
471+ freshTarget.Pebble.sent.slice(sent).map(function (message) {
472+ return message.MESSAGE_TYPE;
473+ }),
474+ [T.RESULT_BEGIN, T.DEPARTURE, T.DEPARTURE, T.RESULT_COMMIT],
475+ name
476+ );
477+
478+ expiredResult.fetchedAt -= contracts.CACHE_FRESH_SECONDS;
479+ storeResult(expiredStorage, expiredResult, expiredClock.now());
480+ expiredTarget = harness({ storage: expiredStorage, clock: expiredClock });
481+ expiredTarget.Pebble.emit("ready");
482+ sent = expiredTarget.Pebble.sent.length;
483+ sendRequest(expiredTarget, "expired-" + index, trigger);
484+ assert.equal(expiredTarget.xhr.instances.length, 1, name);
485+ assert.deepEqual(
486+ expiredTarget.Pebble.sent.slice(sent).map(function (message) {
487+ return message.MESSAGE_TYPE;
488+ }),
489+ [T.RESULT_BEGIN, T.DEPARTURE, T.DEPARTURE, T.RESULT_COMMIT, T.REQUEST],
490+ name
491+ );
492+ assert.equal(expiredTarget.clock.timers.length, 0, name);
493+ });
494+});
495+
496+test("cache age is exact and a suppressed expired cache sends no redundant mirror", function () {
415497 var target = harness();
416498 var before;
417- var resultBegins;
418499
419500 closeWith(target, configurationPayload("REPLACE", TEST_KEY));
420501 sendRequest(target, "network-first");
421- assert.equal(target.xhr.instances.length, 1);
422502 target.xhr.instances[0].respond(200, responseFor("network-first"));
423503
424504 target.clock.advance(59999);
425505 before = target.Pebble.sent.length;
426- sendRequest(target, "cache-hit");
506+ sendRequest(target, "fresh-suppressed", contracts.REQUEST_TRIGGER.MANUAL_SELECT);
427507 assert.equal(target.xhr.instances.length, 1);
428- assert.equal(target.Pebble.sent.length, before + fixture.result.departures.length + 2);
429- resultBegins = messagesOfType(target, T.RESULT_BEGIN);
430- assert.equal(resultBegins[resultBegins.length - 1].REQUEST_ID, "cache-hit");
508+ assert.equal(target.Pebble.sent.length, before);
431509
432510 target.clock.advance(1);
433- sendRequest(target, "cache-expired");
511+ sendRequest(target, "expired-suppressed", contracts.REQUEST_TRIGGER.FAVORITE_SELECTION);
434512 assert.equal(target.xhr.instances.length, 2);
435- assert.equal(target.clock.delays.length, 0);
513+ assert.equal(target.Pebble.sent.length, before);
436514 });
437515
438-test("selection replays by original fetched age and refreshes at exactly 60 seconds", function () {
516+test("an unchanged live signature is cached without a second result batch", function () {
439517 var target = harness();
440- var aged = responseFor("aged-network");
518+ var seed = responseFor("visible-seed");
519+ var unchanged;
520+ var changed;
441521 var before;
522+ var third = {
523+ expectedAt: fixture.result.departures[1].expectedAt + 300,
524+ minutes: 11,
525+ status: "ON_TIME"
526+ };
527+ var fourth = {
528+ expectedAt: fixture.result.departures[1].expectedAt + 600,
529+ minutes: 16,
530+ status: "UNKNOWN"
531+ };
442532
443- aged.fetchedAt = Math.floor(target.clock.now() / 1000) - 59;
533+ seed.departures.push(third, fourth);
444534 closeWith(target, configurationPayload("REPLACE", TEST_KEY));
445- sendRequest(target, "aged-network");
446- target.xhr.instances[0].respond(200, aged);
447- target.clock.advance(1000);
448- before = target.Pebble.sent.length;
449- sendRequest(target, "selection-refresh", contracts.REQUEST_TRIGGER.FAVORITE_SELECTION);
450- assert.equal(
451- target.Pebble.sent.length,
452- before + fixture.result.departures.length + 3
453- );
535+ sendRequest(target, "visible-seed");
536+ target.xhr.instances[0].respond(200, seed);
537+ target.clock.advance(60000);
538+
539+ sendRequest(target, "silent-live", contracts.REQUEST_TRIGGER.MANUAL_SELECT);
454540 assert.equal(target.xhr.instances.length, 2);
541+ before = messagesOfType(target, T.RESULT_BEGIN).length;
542+ unchanged = responseFor("silent-live");
543+ unchanged.departures.push(
544+ Object.assign({}, third),
545+ Object.assign({}, fourth, { expectedAt: fourth.expectedAt + 60 })
546+ );
547+ unchanged.sourceUpdatedAt += 1;
548+ unchanged.departures[0].minutes += 1;
549+ unchanged.departures[0].aimedAt += 1;
550+ target.xhr.instances[1].respond(200, unchanged);
551+ assert.equal(messagesOfType(target, T.RESULT_BEGIN).length, before);
552+
553+ sendRequest(target, "changed-live", contracts.REQUEST_TRIGGER.MANUAL_SELECT);
554+ assert.equal(target.xhr.instances.length, 3);
555+ changed = responseFor("changed-live");
556+ changed.fetchedAt += 60;
557+ target.xhr.instances[2].respond(200, changed);
558+ assert.equal(messagesOfType(target, T.RESULT_BEGIN).length, before + 1);
455559 });
456560
457-test("stale cache commits before the same-request fresh result at the watch receiver", async function () {
458- var protocol = await watchProtocol();
459- var receiver = new protocol.ProtocolReceiver();
460- var target = harness();
461- var fresh = responseFor("cached-refresh");
561+test("an ACKed older result remains suppressible after a later request handoff", function () {
562+ var storage = configuredStorage();
563+ var Pebble = new fakes.FakePebble(false);
564+ var defer = fakes.createDefer(false);
565+ var target = harness({ storage: storage, Pebble: Pebble, defer: defer });
566+ var changed = responseFor("ordered-first");
462567 var before;
463- var statuses;
568+ var index;
464569
465- closeWith(target, configurationPayload("REPLACE", TEST_KEY));
466- sendRequest(target, "network-seed");
467- target.xhr.instances[0].respond(200, responseFor("network-seed"));
468- target.clock.advance(60000);
469-
470- assert.equal(receiver.expectResponse("cached-refresh", fixture.favorite.id), true);
471- before = target.Pebble.sent.length;
472- sendRequest(target, "cached-refresh", contracts.REQUEST_TRIGGER.FAVORITE_SELECTION);
473- statuses = target.Pebble.sent.slice(before).map(function (message) {
474- return receiver.receive(watchMessage(message));
475- });
476- assert.equal(statuses.filter(function (status) {
477- return status === protocol.RECEIVE_RESULT.RESULT_COMMITTED;
478- }).length, 1);
479- assert.equal(receiver.snapshot().result.fetchedAt, fixture.result.fetchedAt);
570+ target.Pebble.emit("ready");
571+ drainQueue(target);
572+ sendRequest(target, "ordered-first");
573+ changed.fetchedAt += 1;
574+ target.xhr.instances[0].respond(200, changed);
575+ drainQueue(target);
480576
481- fresh.fetchedAt += 60;
482577 before = target.Pebble.sent.length;
483- target.xhr.instances[1].respond(200, fresh);
484- statuses = target.Pebble.sent.slice(before).map(function (message) {
485- return receiver.receive(watchMessage(message));
486- });
487- assert.equal(statuses.filter(function (status) {
488- return status === protocol.RECEIVE_RESULT.RESULT_COMMITTED;
489- }).length, 1);
490- assert.equal(receiver.snapshot().result.fetchedAt, fresh.fetchedAt);
578+ for (index = 2; index <= 50; index += 1) {
579+ sendRequest(
580+ target,
581+ "ordered-" + index,
582+ contracts.REQUEST_TRIGGER.MANUAL_SELECT
583+ );
584+ }
585+ assert.equal(target.xhr.instances.length, 1);
586+ assert.equal(target.Pebble.sent.length, before);
587+ assert.equal(target.Pebble.pending.length, 0);
588+ assert.equal(target.defer.pending.length, 0);
491589 });
492590
493591 test("future fetched timestamps cannot extend cache freshness past receipt time", function () {
@@ -746,36 +844,313 @@ test("deleting a favorite prunes its durable result without touching config atom
746844 TEST_KEY
747845 );
748846 });
749-test("a late older response cannot overwrite or emit after the newest request for a favorite", function () {
847+test("same-favorite requests share one XHR and bind a valid launch response to the latest request", function () {
750848 var target = harness();
751- var older = responseFor("older");
752- var newer = responseFor("newer");
753- var beforeOlder;
849+ var outbound;
754850 var resultBegins;
851+ var live = responseFor("launch-request");
755852
756- older.fetchedAt += 1;
757- newer.fetchedAt += 2;
853+ live.fetchedAt += 1;
758854 closeWith(target, configurationPayload("REPLACE", TEST_KEY));
759- sendRequest(target, "older");
760- sendRequest(target, "newer");
761- assert.equal(target.xhr.instances.length, 2);
855+ sendRequest(target, "launch-request");
856+ sendRequest(target, "latest-request", contracts.REQUEST_TRIGGER.MANUAL_SELECT);
857+ assert.equal(target.xhr.instances.length, 1);
858+ outbound = JSON.parse(target.xhr.instances[0].body);
859+ assert.deepEqual(outbound, {
860+ schemaVersion: contracts.SCHEMA_VERSION,
861+ requestId: "launch-request",
862+ favoriteId: fixture.favorite.id,
863+ serviceId: fixture.favorite.serviceId
864+ });
762865
763- target.xhr.instances[1].respond(200, newer);
866+ target.xhr.instances[0].respond(200, live);
764867 resultBegins = messagesOfType(target, T.RESULT_BEGIN);
765- assert.equal(resultBegins[resultBegins.length - 1].REQUEST_ID, "newer");
766- assert.equal(resultBegins[resultBegins.length - 1].FETCHED_AT, newer.fetchedAt);
868+ assert.equal(resultBegins.length, 1);
869+ assert.equal(resultBegins[0].REQUEST_ID, "latest-request");
870+ assert.equal(resultBegins[0].FETCHED_AT, live.fetchedAt);
871+});
872+
873+test("same-favorite single-flight validates the immutable launch identity", function () {
874+ var target = harness();
875+ var errors;
876+
877+ closeWith(target, configurationPayload("REPLACE", TEST_KEY));
878+ sendRequest(target, "immutable-launch");
879+ sendRequest(target, "latest-binding", contracts.REQUEST_TRIGGER.FAVORITE_SELECTION);
880+ assert.equal(target.xhr.instances.length, 1);
881+ target.xhr.instances[0].respond(200, responseFor("latest-binding"));
882+
883+ assert.equal(messagesOfType(target, T.RESULT_BEGIN).length, 0);
884+ errors = messagesOfType(target, T.ERROR);
885+ assert.equal(errors.length, 1);
886+ assert.equal(errors[0].REQUEST_ID, "latest-binding");
887+ assert.equal(
888+ errors[0].ERROR_CODE,
889+ contracts.ERROR_CODE.indexOf("INVALID_RESPONSE")
890+ );
891+});
767892
768- beforeOlder = target.Pebble.sent.length;
769- target.xhr.instances[0].respond(200, older);
770- assert.equal(target.Pebble.sent.length, beforeOlder);
771893
772- sendRequest(target, "cache-after-race");
894+test("missing, invalid, empty, and unknown favorite paths start zero XHR", function () {
895+ var missing = harness();
896+ var invalid = harness({
897+ storage: configuredStorage([fixture.favorite], contracts.KEY_STATUS.INVALID)
898+ });
899+ var empty = harness();
900+ var unknown = harness({ storage: configuredStorage() });
901+ var errors;
902+
903+ closeWith(missing, configurationPayload("REMOVE"));
904+ sendRequest(missing, "missing-key");
905+ assert.equal(missing.xhr.instances.length, 0);
906+ errors = messagesOfType(missing, T.ERROR);
907+ assert.equal(errors[0].ERROR_CODE, contracts.ERROR_CODE.indexOf("API_KEY_REQUIRED"));
908+
909+ invalid.Pebble.emit("ready");
910+ sendRequest(invalid, "invalid-key");
911+ assert.equal(invalid.xhr.instances.length, 0);
912+ errors = messagesOfType(invalid, T.ERROR);
913+ assert.equal(errors[0].ERROR_CODE, contracts.ERROR_CODE.indexOf("API_KEY_INVALID"));
914+
915+ closeWith(empty, configurationPayload("REPLACE", TEST_KEY, []));
916+ sendRequest(empty, "no-favorite");
917+ assert.equal(empty.xhr.instances.length, 0);
918+ errors = messagesOfType(empty, T.ERROR);
919+ assert.equal(errors[0].ERROR_CODE, contracts.ERROR_CODE.indexOf("INVALID_SERVICE"));
920+
921+ unknown.Pebble.emit("ready");
922+ sendRequest(unknown, "unknown-favorite", undefined, SECOND_FAVORITE);
923+ assert.equal(unknown.xhr.instances.length, 0);
924+ errors = messagesOfType(unknown, T.ERROR);
925+ assert.equal(errors[0].ERROR_CODE, contracts.ERROR_CODE.indexOf("INVALID_SERVICE"));
926+});
927+
928+test("different favorites own independent flights and inactive completion is cache-only", function () {
929+ var target = harness();
930+ var first = responseFor("a-seed");
931+ var aBegins;
932+ var before;
933+
934+ first.fetchedAt += 60;
935+ closeWith(target, configurationPayload(
936+ "REPLACE",
937+ TEST_KEY,
938+ [fixture.favorite, SECOND_FAVORITE]
939+ ));
940+ sendRequest(target, "a-seed");
941+ target.xhr.instances[0].respond(200, first);
942+ target.clock.advance(60000);
943+
944+ sendRequest(target, "a-refresh", contracts.REQUEST_TRIGGER.MANUAL_SELECT);
945+ sendRequest(
946+ target,
947+ "b-launch",
948+ contracts.REQUEST_TRIGGER.FAVORITE_SELECTION,
949+ SECOND_FAVORITE
950+ );
951+ assert.equal(target.xhr.instances.length, 3);
952+ before = messagesOfType(target, T.RESULT_BEGIN).length;
953+
954+ target.xhr.instances[1].respond(
955+ 200,
956+ contracts.copyDepartureResult(first, "a-refresh")
957+ );
958+ assert.equal(messagesOfType(target, T.RESULT_BEGIN).length, before);
959+ target.xhr.instances[2].respond(200, responseFor("b-launch", SECOND_FAVORITE));
960+
961+ sendRequest(target, "a-return", contracts.REQUEST_TRIGGER.FAVORITE_SELECTION);
962+ assert.equal(target.xhr.instances.length, 3);
963+ aBegins = messagesOfType(target, T.RESULT_BEGIN).filter(function (message) {
964+ return message.FAVORITE_ID === fixture.favorite.id;
965+ });
966+ assert.equal(aBegins[aBegins.length - 1].REQUEST_ID, "a-return");
967+ assert.equal(aBegins[aBegins.length - 1].FETCHED_AT, first.fetchedAt);
968+});
969+
970+test("an inactive favorite failure is transport-inert while the current error is sent", function () {
971+ var target = harness();
972+ var before;
973+ var errors;
974+
975+ closeWith(target, configurationPayload(
976+ "REPLACE",
977+ TEST_KEY,
978+ [fixture.favorite, SECOND_FAVORITE]
979+ ));
980+ sendRequest(target, "inactive-failure");
981+ sendRequest(
982+ target,
983+ "current-failure",
984+ contracts.REQUEST_TRIGGER.FAVORITE_SELECTION,
985+ SECOND_FAVORITE
986+ );
773987 assert.equal(target.xhr.instances.length, 2);
774- resultBegins = messagesOfType(target, T.RESULT_BEGIN);
775- assert.equal(resultBegins[resultBegins.length - 1].REQUEST_ID, "cache-after-race");
776- assert.equal(resultBegins[resultBegins.length - 1].FETCHED_AT, newer.fetchedAt);
988+ before = target.Pebble.sent.length;
989+ target.xhr.instances[0].networkError();
990+ assert.equal(target.Pebble.sent.length, before);
991+
992+ target.xhr.instances[1].networkError();
993+ errors = messagesOfType(target, T.ERROR);
994+ assert.equal(errors.length, 1);
995+ assert.equal(errors[0].REQUEST_ID, "current-failure");
996+ assert.equal(errors[0].FAVORITE_ID, SECOND_FAVORITE.id);
997+});
998+
999+test("visible signature changes each enqueue one atomic replacement", function () {
1000+ var cases = [
1001+ {
1002+ name: "fetchedAt",
1003+ change: function (result) { result.fetchedAt += 60; }
1004+ },
1005+ {
1006+ name: "freshness",
1007+ change: function (result) { result.freshness = "SCHEDULED"; }
1008+ },
1009+ {
1010+ name: "expectedAt",
1011+ change: function (result) { result.departures[0].expectedAt += 1; }
1012+ },
1013+ {
1014+ name: "status",
1015+ change: function (result) { result.departures[0].status = "CANCELLED"; }
1016+ },
1017+ {
1018+ name: "nextIntervalMinutes",
1019+ change: function (result) { delete result.departures[0].nextIntervalMinutes; }
1020+ }
1021+ ];
1022+
1023+ cases.forEach(function (item, index) {
1024+ var target = harness();
1025+ var seed = responseFor("signature-seed-" + index);
1026+ var replacement;
1027+ var before;
1028+ closeWith(target, configurationPayload("REPLACE", TEST_KEY));
1029+ sendRequest(target, "signature-seed-" + index);
1030+ target.xhr.instances[0].respond(200, seed);
1031+ target.clock.advance(60000);
1032+ sendRequest(target, "signature-live-" + index);
1033+ before = messagesOfType(target, T.RESULT_BEGIN).length;
1034+ replacement = responseFor("signature-live-" + index);
1035+ item.change(replacement);
1036+ target.xhr.instances[1].respond(200, replacement);
1037+ assert.equal(
1038+ messagesOfType(target, T.RESULT_BEGIN).length,
1039+ before + 1,
1040+ item.name
1041+ );
1042+ });
7771043 });
7781044
1045+test("delayed ACKs retain only one latest-bound same-signature replacement", function () {
1046+ var storage = configuredStorage();
1047+ var clock = new fakes.FakeClock();
1048+ var Pebble = new fakes.FakePebble(false);
1049+ var defer = fakes.createDefer(false);
1050+ var stored = responseFor("coalesced-stored");
1051+ var target;
1052+ var baseline;
1053+ var latestRequestId = null;
1054+ var sent;
1055+ var batchLength;
1056+ var index;
1057+
1058+ stored.fetchedAt -= contracts.CACHE_FRESH_SECONDS;
1059+ storeResult(storage, stored, clock.now());
1060+ target = harness({ storage: storage, clock: clock, Pebble: Pebble, defer: defer });
1061+ target.Pebble.emit("ready");
1062+ drainQueue(target);
1063+ baseline = target.Pebble.sent.length;
1064+
1065+ sendRequest(target, "coalesced-1");
1066+ for (index = 2; index <= 50; index += 1) {
1067+ latestRequestId = "coalesced-" + index;
1068+ sendRequest(
1069+ target,
1070+ latestRequestId,
1071+ contracts.REQUEST_TRIGGER.MANUAL_SELECT
1072+ );
1073+ }
1074+ assert.equal(target.xhr.instances.length, 1);
1075+ assert.equal(target.Pebble.sent.length, baseline + 1);
1076+
1077+ drainQueue(target);
1078+ sent = target.Pebble.sent.slice(baseline);
1079+ batchLength = stored.departures.length + 3;
1080+ assert.equal(sent.length, batchLength * 2);
1081+ assert.equal(sent.slice(0, batchLength).every(function (message) {
1082+ return message.REQUEST_ID === "coalesced-1";
1083+ }), true);
1084+ assert.equal(sent.slice(batchLength).every(function (message) {
1085+ return message.REQUEST_ID === latestRequestId;
1086+ }), true);
1087+ assert.deepEqual(sent.map(function (message) {
1088+ return message.MESSAGE_TYPE;
1089+ }), [
1090+ T.RESULT_BEGIN,
1091+ T.DEPARTURE,
1092+ T.DEPARTURE,
1093+ T.RESULT_COMMIT,
1094+ T.REQUEST,
1095+ T.RESULT_BEGIN,
1096+ T.DEPARTURE,
1097+ T.DEPARTURE,
1098+ T.RESULT_COMMIT,
1099+ T.REQUEST
1100+ ]);
1101+ assert.equal(target.Pebble.maxInFlight, 1);
1102+ assert.equal(target.Pebble.pending.length, 0);
1103+ assert.equal(target.defer.pending.length, 0);
1104+});
1105+
1106+test("queue NACK clears superseded result batches without retrying or duplicating XHR", function () {
1107+ var storage = configuredStorage();
1108+ var clock = new fakes.FakeClock();
1109+ var Pebble = new fakes.FakePebble(false);
1110+ var defer = fakes.createDefer(false);
1111+ var stored = responseFor("stored-pending");
1112+ var target;
1113+ var before;
1114+
1115+ stored.fetchedAt -= 60;
1116+ storeResult(storage, stored, clock.now());
1117+ target = harness({ storage: storage, clock: clock, Pebble: Pebble, defer: defer });
1118+ target.Pebble.emit("ready");
1119+ drainQueue(target);
1120+
1121+ sendRequest(target, "pending-first");
1122+ assert.equal(target.xhr.instances.length, 1);
1123+ before = target.Pebble.sent.length;
1124+ sendRequest(target, "pending-latest", contracts.REQUEST_TRIGGER.MANUAL_SELECT);
1125+ assert.equal(target.Pebble.sent.length, before);
1126+ assert.equal(target.xhr.instances.length, 1);
1127+
1128+ target.Pebble.fail();
1129+ assert.equal(target.Pebble.sent.length, before);
1130+ assert.equal(target.defer.pending.length, 0);
1131+ sendRequest(target, "pending-after-nack");
1132+ assert.equal(target.Pebble.sent.length, before + 1);
1133+ assert.equal(target.xhr.instances.length, 1);
1134+});
1135+
1136+test("accepted configuration changes abort flights and make late callbacks inert", function () {
1137+ var target = harness();
1138+ var flight;
1139+ var before;
1140+
1141+ closeWith(target, configurationPayload("REPLACE", TEST_KEY));
1142+ sendRequest(target, "before-config-change");
1143+ flight = target.xhr.instances[0];
1144+ flight.abort = function () { this.aborted = true; };
1145+ closeWith(target, configurationPayload("KEEP"));
1146+ assert.equal(flight.aborted, true);
1147+
1148+ before = target.Pebble.sent.length;
1149+ flight.respond(200, responseFor("before-config-change"));
1150+ assert.equal(target.Pebble.sent.length, before);
1151+ sendRequest(target, "after-config-change");
1152+ assert.equal(target.xhr.instances.length, 2);
1153+});
7791154
7801155 test("HTTP failures map to stable error codes, keyStatus only, and never retry", function () {
7811156 var cases = [
@@ -820,25 +1195,62 @@ test("HTTP failures map to stable error codes, keyStatus only, and never retry",
8201195 });
8211196 });
8221197
823-test("network failure emits one sanitized error and schedules no retry", function () {
1198+test("network failure emits one latest-bound sanitized error without retry", function () {
8241199 var target = harness();
8251200 var errors;
8261201 closeWith(target, configurationPayload("REPLACE", TEST_KEY));
827- sendRequest(target, "network-error");
1202+ sendRequest(target, "network-launch");
1203+ sendRequest(target, "network-latest", contracts.REQUEST_TRIGGER.MANUAL_SELECT);
1204+ assert.equal(target.xhr.instances.length, 1);
8281205 target.xhr.instances[0].networkError();
8291206 errors = messagesOfType(target, T.ERROR);
8301207 assert.equal(errors.length, 1);
1208+ assert.equal(errors[0].REQUEST_ID, "network-latest");
8311209 assert.equal(errors[0].ERROR_CODE, contracts.ERROR_CODE.indexOf("SOURCE_UNAVAILABLE"));
8321210 assert.equal(target.xhr.instances.length, 1);
8331211 assert.equal(target.clock.timers.length, 0);
8341212 assert.equal(JSON.stringify(errors).includes(TEST_KEY), false);
8351213 });
836-test("build bootstrap validates URLs and injects only the zero-delay ACK defer", async function () {
1214+
1215+test("throwing XHR constructor emits one current sanitized error without retry", function () {
1216+ var target;
1217+ var errors;
1218+
1219+ function ThrowingXHR() {
1220+ throw new Error("constructor failure " + TEST_KEY);
1221+ }
1222+
1223+ target = harness({
1224+ storage: configuredStorage(),
1225+ XHR: ThrowingXHR
1226+ });
1227+ sendRequest(target, "constructor-failure", contracts.REQUEST_TRIGGER.MANUAL_SELECT);
1228+
1229+ errors = messagesOfType(target, T.ERROR);
1230+ assert.equal(errors.length, 1);
1231+ assert.equal(errors[0].REQUEST_ID, "constructor-failure");
1232+ assert.equal(errors[0].FAVORITE_ID, fixture.favorite.id);
1233+ assert.equal(
1234+ errors[0].ERROR_CODE,
1235+ contracts.ERROR_CODE.indexOf("SOURCE_UNAVAILABLE")
1236+ );
1237+ assert.equal(target.xhr.instances.length, 0);
1238+ assert.equal(target.companion.metrics().requests, 0);
1239+ assert.equal(target.companion.metrics().failures, 1);
1240+ assert.equal(target.clock.timers.length, 0);
1241+ assert.equal(JSON.stringify(errors).includes(TEST_KEY), false);
1242+
1243+ target.clock.advance(3600000);
1244+ assert.equal(messagesOfType(target, T.ERROR).length, 1);
1245+});
1246+
1247+test("build bootstrap keeps ACK and one-shot ready settling schedulers distinct", async function () {
8371248 var build = await import("../../../scripts/build.mjs");
8381249 var configured;
8391250 var captured = null;
8401251 var scheduled = [];
8411252 var deferred = false;
1253+ var readyDeferred = false;
8421254 var source;
8431255
8441256 assert.deepEqual(build.readBuildConfiguration(Object.create(null)), {
@@ -859,7 +1271,7 @@ test("build bootstrap validates URLs and injects only the zero-delay ACK defer",
8591271 LAPIN_FUTE_BACKEND_URL: "https://backend.example.test/departures",
8601272 LAPIN_FUTE_CONFIG_URL: "https://config.example.test/index.html"
8611273 });
862- source = build.createBootstrap(fixture, configured);
1274+ source = build.createBootstrap(configured);
8631275 vm.runInNewContext(source, {
8641276 Pebble: {},
8651277 localStorage: {},
@@ -881,14 +1293,29 @@ test("build bootstrap validates URLs and injects only the zero-delay ACK defer",
8811293 assert.notEqual(captured, null);
8821294 assert.deepEqual(Object.keys(captured.clock), ["now"]);
8831295 captured.defer(function () { deferred = true; });
1296+ captured.readyDefer(function () { readyDeferred = true; });
8841297 assert.equal(deferred, false);
885- assert.equal(scheduled.length, 1);
1298+ assert.equal(readyDeferred, false);
1299+ assert.equal(scheduled.length, 2);
8861300 assert.equal(scheduled[0].delay, 0);
1301+ assert.equal(scheduled[1].delay, 250);
8871302 scheduled[0].callback();
1303+ scheduled[1].callback();
8881304 assert.equal(deferred, true);
1305+ assert.equal(readyDeferred, true);
8891306 assert.equal(captured.backendUrl, configured.backendUrl);
8901307 assert.equal(captured.configurationUrl, configured.configurationUrl);
891- assert.equal(JSON.stringify(captured.fixture), JSON.stringify(fixture));
1308+ assert.deepEqual(Object.keys(captured), [
1309+ "Pebble",
1310+ "storage",
1311+ "XHR",
1312+ "clock",
1313+ "defer",
1314+ "readyDefer",
1315+ "backendUrl",
1316+ "configurationUrl"
1317+ ]);
1318+ assert.equal(source.includes("fixture"), false);
8921319 assert.equal(source.includes("LAPIN_FUTE_"), false);
8931320 assert.equal(source.includes(TEST_KEY), false);
8941321 });
@@ -3,8 +3,6 @@
3 var test = require("node:test");3 var test = require("node:test");
4 var assert = require("node:assert/strict");4 var assert = require("node:assert/strict");
5 var vm = require("node:vm");5 var vm = require("node:vm");
6-var path = require("node:path");
7-var url = require("node:url");
8 var companion = require("../src");6 var companion = require("../src");
9 var fixture = require("../../../fixtures/departures/foundation.json");7 var fixture = require("../../../fixtures/departures/foundation.json");
10 var fakes = require("./fakes");8 var fakes = require("./fakes");
@@ -14,6 +12,13 @@ var codec = companion.codec;
14 var configuration = companion.configuration;12 var configuration = companion.configuration;
15 var T = contracts.MESSAGE_TYPE;13 var T = contracts.MESSAGE_TYPE;
16 var TEST_KEY = "test-personal-prim-key";14 var TEST_KEY = "test-personal-prim-key";
15+var SECOND_FAVORITE = Object.freeze(Object.assign({}, fixture.favorite, {
16+ id: "foundation-work",
17+ serviceId: "opaque:foundation:service:2",
18+ displayName: "Commute work",
19+ destinationLabel: "Château de Vincennes",
20+ sortOrder: 1
21+}));
17 22
18 function harness(options) {23 function harness(options) {
19 var Pebble;24 var Pebble;
@@ -21,6 +26,7 @@ function harness(options) {
21 var clock;26 var clock;
22 var xhr;27 var xhr;
23 var defer;28 var defer;
29+ var readyDefer;
24 var instance;30 var instance;
25 options = options || {};31 options = options || {};
26 Pebble = options.Pebble || new fakes.FakePebble(true);32 Pebble = options.Pebble || new fakes.FakePebble(true);
@@ -28,30 +34,34 @@ function harness(options) {
28 clock = options.clock || new fakes.FakeClock();34 clock = options.clock || new fakes.FakeClock();
29 xhr = fakes.createXHRFactory();35 xhr = fakes.createXHRFactory();
30 defer = typeof options.defer === "function" ? options.defer : fakes.createDefer(true);36 defer = typeof options.defer === "function" ? options.defer : fakes.createDefer(true);
37+ readyDefer = typeof options.readyDefer === "function"
38+ ? options.readyDefer
39+ : fakes.createDefer(true);
31 instance = companion.createCompanion({40 instance = companion.createCompanion({
32 Pebble: Pebble,41 Pebble: Pebble,
33 storage: storage,42 storage: storage,
34- XHR: xhr.XHR,43+ XHR: options.XHR || xhr.XHR,
35 clock: clock,44 clock: clock,
36 defer: defer,45 defer: defer,
46+ readyDefer: readyDefer,
37 backendUrl: "https://backend.example.test/departures",47 backendUrl: "https://backend.example.test/departures",
38- configurationUrl: "https://config.example.test/index.html",48+ configurationUrl: "https://config.example.test/index.html"
39- fixture: options.fixture
40 });49 });
41 return {50 return {
42 Pebble: Pebble,51 Pebble: Pebble,
43 storage: storage,52 storage: storage,
44 clock: clock,53 clock: clock,
45 defer: defer,54 defer: defer,
55+ readyDefer: readyDefer,
46 xhr: xhr,56 xhr: xhr,
47 companion: instance57 companion: instance
48 };58 };
49 }59 }
50 60
51-function configurationPayload(action, key) {61+function configurationPayload(action, key, favorites) {
52 var update = {62 var update = {
53 schemaVersion: contracts.SCHEMA_VERSION,63 schemaVersion: contracts.SCHEMA_VERSION,
54- favorites: [fixture.favorite],64+ favorites: favorites || [fixture.favorite],
55 apiKeyUpdate: { schemaVersion: contracts.SCHEMA_VERSION, action: action }65 apiKeyUpdate: { schemaVersion: contracts.SCHEMA_VERSION, action: action }
56 };66 };
57 if (action === "REPLACE") update.apiKeyUpdate.value = key;67 if (action === "REPLACE") update.apiKeyUpdate.value = key;
@@ -64,11 +74,11 @@ function closeWith(target, payload) {
64 });74 });
65 }75 }
66 76
67-function sendRequest(target, requestId, trigger) {77+function sendRequest(target, requestId, trigger, favorite) {
68 target.Pebble.emit("appmessage", {78 target.Pebble.emit("appmessage", {
69 payload: codec.encodeRequest({79 payload: codec.encodeRequest({
70 requestId: requestId,80 requestId: requestId,
71- favoriteId: fixture.favorite.id,81+ favoriteId: (favorite || fixture.favorite).id,
72 trigger: typeof trigger === "number" ? trigger : contracts.REQUEST_TRIGGER.APP_OPEN82 trigger: typeof trigger === "number" ? trigger : contracts.REQUEST_TRIGGER.APP_OPEN
73 })83 })
74 });84 });
@@ -95,22 +105,46 @@ function messagesOfType(target, type) {
95 });105 });
96 }106 }
97 107
98-function responseFor(requestId) {108+function responseFor(requestId, favorite) {
99 var result = contracts.copyDepartureResult(fixture.result, requestId);109 var result = contracts.copyDepartureResult(fixture.result, requestId);
110+ result.favoriteId = (favorite || fixture.favorite).id;
100 return result;111 return result;
101 }112 }
102 113
103-async function watchProtocol() {114+function configuredStorage(favorites, keyStatus) {
104- var protocolPath = path.resolve(__dirname, "../../watch/src/embeddedjs/protocol.js");115+ var storage = new fakes.FakeStorage();
105- return import(url.pathToFileURL(protocolPath).href);116+ configuration.saveConfiguration(storage, {
117+ schemaVersion: contracts.SCHEMA_VERSION,
118+ favorites: favorites || [fixture.favorite],
119+ primApiKey: TEST_KEY,
120+ keyStatus: typeof keyStatus === "number"
121+ ? keyStatus
122+ : contracts.KEY_STATUS.CONFIGURED
123+ });
124+ storage.writes.length = 0;
125+ return storage;
106 }126 }
107 127
108-function watchMessage(message) {128+function storeResult(storage, result, storedAt) {
109- return new Map(Object.keys(message).map(function (key) {129+ configuration.saveResults(storage, {
110- return [key, message[key]];130+ schemaVersion: contracts.SCHEMA_VERSION,
111- }));131+ results: [{
132+ favoriteId: result.favoriteId,
133+ storedAt: storedAt,
134+ result: result
135+ }]
136+ });
137+ storage.writes.length = 0;
112 }138 }
113 139
140+function drainQueue(target) {
141+ while (target.Pebble.pending.length > 0 || target.defer.pending.length > 0) {
142+ if (target.Pebble.pending.length > 0) target.Pebble.ack();
143+ else target.defer.runNext();
144+ }
145+}
146+
147+
114 test("close fragment atomically stores one favorites-and-key record and exposes the key only to Authorization", function () {148 test("close fragment atomically stores one favorites-and-key record and exposes the key only to Authorization", function () {
115 var target = harness();149 var target = harness();
116 var stored;150 var stored;
@@ -268,11 +302,21 @@ test("untrusted webview responses are bounded and cannot persist a key inside fa
268 });302 });
269 303
270 304
271-test("configuration advances one event-loop turn at a time after immediate ACKs", function () {305+test("ready settling is distinct from ACK backpressure deferrals", function () {
272 var defer = fakes.createDefer(false);306 var defer = fakes.createDefer(false);
273- var target = harness({ fixture: fixture, defer: defer });307+ var readyDefer = fakes.createDefer(false);
308+ var target = harness({
309+ storage: configuredStorage(),
310+ defer: defer,
311+ readyDefer: readyDefer
312+ });
274 313
275 target.Pebble.emit("ready");314 target.Pebble.emit("ready");
315+ assert.deepEqual(target.Pebble.sent, []);
316+ assert.equal(readyDefer.pending.length, 1);
317+ assert.equal(defer.pending.length, 0);
318+
319+ readyDefer.runNext();
276 assert.deepEqual(target.Pebble.sent.map(function (message) { return message.MESSAGE_TYPE; }), [320 assert.deepEqual(target.Pebble.sent.map(function (message) { return message.MESSAGE_TYPE; }), [
277 T.CONFIG_BEGIN321 T.CONFIG_BEGIN
278 ]);322 ]);
@@ -294,16 +338,18 @@ test("configuration advances one event-loop turn at a time after immediate ACKs"
294 assert.equal(defer.pending.length, 0);338 assert.equal(defer.pending.length, 0);
295 });339 });
296 340
297-test("stop invalidates a deferred configuration advance", function () {341+test("stop invalidates a deferred ready synchronization", function () {
298- var defer = fakes.createDefer(false);342+ var readyDefer = fakes.createDefer(false);
299- var target = harness({ fixture: fixture, defer: defer });343+ var target = harness({
344+ storage: configuredStorage(),
345+ readyDefer: readyDefer
346+ });
300 347
301 target.Pebble.emit("ready");348 target.Pebble.emit("ready");
349+ assert.deepEqual(target.Pebble.sent, []);
302 target.companion.stop();350 target.companion.stop();
303- defer.runNext();351+ readyDefer.runNext();
304- assert.deepEqual(target.Pebble.sent.map(function (message) { return message.MESSAGE_TYPE; }), [352+ assert.deepEqual(target.Pebble.sent, []);
305- T.CONFIG_BEGIN
306- ]);
307 });353 });
308 354
309 test("stop aborts and invalidates a late request callback across restart", function () {355 test("stop aborts and invalidates a late request callback across restart", function () {
@@ -332,34 +378,23 @@ test("stop aborts and invalidates a late request callback across restart", funct
332 assert.equal(resultBegins[resultBegins.length - 1].REQUEST_ID, "after-restart");378 assert.equal(resultBegins[resultBegins.length - 1].REQUEST_ID, "after-restart");
333 });379 });
334 380
335-test("ready sends full config and APP_OPEN returns the fixture without XHR or product timers", function () {381+test("ready keeps a missing-key configuration with no favorites unconfigured", function () {
336- var target = harness({ fixture: fixture });382+ var target = harness();
337- var before;
338- var resultBegins;
339 383
340 target.Pebble.emit("ready");384 target.Pebble.emit("ready");
341 assert.deepEqual(target.Pebble.sent.map(function (message) { return message.MESSAGE_TYPE; }), [385 assert.deepEqual(target.Pebble.sent.map(function (message) { return message.MESSAGE_TYPE; }), [
342 T.CONFIG_BEGIN,386 T.CONFIG_BEGIN,
343- T.FAVORITE,
344 T.CONFIG_COMMIT387 T.CONFIG_COMMIT
345 ]);388 ]);
346 assert.equal(target.Pebble.sent[0].DISPLAY_NAME, "en");389 assert.equal(target.Pebble.sent[0].DISPLAY_NAME, "en");
347- assert.equal(target.Pebble.sent[0].KEY_STATUS, contracts.KEY_STATUS.CONFIGURED);390+ assert.equal(target.Pebble.sent[0].KEY_STATUS, contracts.KEY_STATUS.MISSING);
348- assert.equal(target.Pebble.sent.every(function (message) {391+ assert.equal(JSON.stringify(target.Pebble.sent).includes(fixture.favorite.id), false);
349- return Object.keys(message).every(function (key) { return !/^[0-9]+$/.test(key); });
350- }), true);
351-
352- before = target.Pebble.sent.length;
353- sendRequest(target, "app-open");
354- assert.equal(target.Pebble.sent.length, before + fixture.result.departures.length + 2);
355- resultBegins = messagesOfType(target, T.RESULT_BEGIN);
356- assert.equal(resultBegins[resultBegins.length - 1].REQUEST_ID, "app-open");
357 assert.equal(target.xhr.instances.length, 0);392 assert.equal(target.xhr.instances.length, 0);
358 assert.equal(target.clock.timers.length, 0);393 assert.equal(target.clock.timers.length, 0);
359 });394 });
360 395
361 test("pypkjs dual request properties are canonicalized only at the event boundary", function () {396 test("pypkjs dual request properties are canonicalized only at the event boundary", function () {
362- var target = harness({ fixture: fixture });397+ var target = harness({ storage: configuredStorage() });
363 var payload = dualRequestPayload("dual-app-open");398 var payload = dualRequestPayload("dual-app-open");
364 var before;399 var before;
365 var resultBegins;400 var resultBegins;
@@ -368,16 +403,18 @@ test("pypkjs dual request properties are canonicalized only at the event boundar
368 before = target.Pebble.sent.length;403 before = target.Pebble.sent.length;
369 assert.equal(codec.decodeRequest(payload), null);404 assert.equal(codec.decodeRequest(payload), null);
370 target.Pebble.emit("appmessage", { payload: payload });405 target.Pebble.emit("appmessage", { payload: payload });
406+ assert.equal(target.xhr.instances.length, 1);
407+ target.xhr.instances[0].respond(200, responseFor("dual-app-open"));
371 408
372 assert.equal(target.Pebble.sent.length, before + fixture.result.departures.length + 2);409 assert.equal(target.Pebble.sent.length, before + fixture.result.departures.length + 2);
373 resultBegins = messagesOfType(target, T.RESULT_BEGIN);410 resultBegins = messagesOfType(target, T.RESULT_BEGIN);
374 assert.equal(resultBegins[resultBegins.length - 1].REQUEST_ID, "dual-app-open");411 assert.equal(resultBegins[resultBegins.length - 1].REQUEST_ID, "dual-app-open");
375- assert.equal(target.xhr.instances.length, 0);412+ assert.equal(target.xhr.instances.length, 1);
376 assert.equal(target.clock.timers.length, 0);413 assert.equal(target.clock.timers.length, 0);
377 });414 });
378 415
379 test("incoming event normalization rejects numeric-only, unknown, conflicting, extra, and internal properties", function () {416 test("incoming event normalization rejects numeric-only, unknown, conflicting, extra, and internal properties", function () {
380- var target = harness({ fixture: fixture });417+ var target = harness({ storage: configuredStorage() });
381 var numericOnly = dualRequestPayload("numeric-only");418 var numericOnly = dualRequestPayload("numeric-only");
382 var unknown = dualRequestPayload("unknown");419 var unknown = dualRequestPayload("unknown");
383 var conflicting = dualRequestPayload("conflicting");420 var conflicting = dualRequestPayload("conflicting");
@@ -411,83 +448,144 @@ test("incoming event normalization rejects numeric-only, unknown, conflicting, e
411 assert.equal(target.clock.timers.length, 0);448 assert.equal(target.clock.timers.length, 0);
412 });449 });
413 450
414-test("fresh results are rebound from cache for 60 seconds without a network timer", function () {451+test("all three triggers send fresh cache with zero XHR and expired cache before one XHR", function () {
452+ Object.keys(contracts.REQUEST_TRIGGER).forEach(function (name, index) {
453+ var trigger = contracts.REQUEST_TRIGGER[name];
454+ var freshClock = new fakes.FakeClock();
455+ var freshStorage = configuredStorage();
456+ var freshResult = responseFor("stored-fresh-" + index);
457+ var freshTarget;
458+ var expiredClock = new fakes.FakeClock();
459+ var expiredStorage = configuredStorage();
460+ var expiredResult = responseFor("stored-expired-" + index);
461+ var expiredTarget;
462+ var sent;
463+
464+ storeResult(freshStorage, freshResult, freshClock.now());
465+ freshTarget = harness({ storage: freshStorage, clock: freshClock });
466+ freshTarget.Pebble.emit("ready");
467+ sent = freshTarget.Pebble.sent.length;
468+ sendRequest(freshTarget, "fresh-" + index, trigger);
469+ assert.equal(freshTarget.xhr.instances.length, 0, name);
470+ assert.deepEqual(
471+ freshTarget.Pebble.sent.slice(sent).map(function (message) {
472+ return message.MESSAGE_TYPE;
473+ }),
474+ [T.RESULT_BEGIN, T.DEPARTURE, T.DEPARTURE, T.RESULT_COMMIT],
475+ name
476+ );
477+
478+ expiredResult.fetchedAt -= contracts.CACHE_FRESH_SECONDS;
479+ storeResult(expiredStorage, expiredResult, expiredClock.now());
480+ expiredTarget = harness({ storage: expiredStorage, clock: expiredClock });
481+ expiredTarget.Pebble.emit("ready");
482+ sent = expiredTarget.Pebble.sent.length;
483+ sendRequest(expiredTarget, "expired-" + index, trigger);
484+ assert.equal(expiredTarget.xhr.instances.length, 1, name);
485+ assert.deepEqual(
486+ expiredTarget.Pebble.sent.slice(sent).map(function (message) {
487+ return message.MESSAGE_TYPE;
488+ }),
489+ [T.RESULT_BEGIN, T.DEPARTURE, T.DEPARTURE, T.RESULT_COMMIT, T.REQUEST],
490+ name
491+ );
492+ assert.equal(expiredTarget.clock.timers.length, 0, name);
493+ });
494+});
495+
496+test("cache age is exact and a suppressed expired cache sends no redundant mirror", function () {
415 var target = harness();497 var target = harness();
416 var before;498 var before;
417- var resultBegins;
418 499
419 closeWith(target, configurationPayload("REPLACE", TEST_KEY));500 closeWith(target, configurationPayload("REPLACE", TEST_KEY));
420 sendRequest(target, "network-first");501 sendRequest(target, "network-first");
421- assert.equal(target.xhr.instances.length, 1);
422 target.xhr.instances[0].respond(200, responseFor("network-first"));502 target.xhr.instances[0].respond(200, responseFor("network-first"));
423 503
424 target.clock.advance(59999);504 target.clock.advance(59999);
425 before = target.Pebble.sent.length;505 before = target.Pebble.sent.length;
426- sendRequest(target, "cache-hit");506+ sendRequest(target, "fresh-suppressed", contracts.REQUEST_TRIGGER.MANUAL_SELECT);
427 assert.equal(target.xhr.instances.length, 1);507 assert.equal(target.xhr.instances.length, 1);
428- assert.equal(target.Pebble.sent.length, before + fixture.result.departures.length + 2);508+ assert.equal(target.Pebble.sent.length, before);
429- resultBegins = messagesOfType(target, T.RESULT_BEGIN);
430- assert.equal(resultBegins[resultBegins.length - 1].REQUEST_ID, "cache-hit");
431 509
432 target.clock.advance(1);510 target.clock.advance(1);
433- sendRequest(target, "cache-expired");511+ sendRequest(target, "expired-suppressed", contracts.REQUEST_TRIGGER.FAVORITE_SELECTION);
434 assert.equal(target.xhr.instances.length, 2);512 assert.equal(target.xhr.instances.length, 2);
435- assert.equal(target.clock.delays.length, 0);513+ assert.equal(target.Pebble.sent.length, before);
436 });514 });
437 515
438-test("selection replays by original fetched age and refreshes at exactly 60 seconds", function () {516+test("an unchanged live signature is cached without a second result batch", function () {
439 var target = harness();517 var target = harness();
440- var aged = responseFor("aged-network");518+ var seed = responseFor("visible-seed");
519+ var unchanged;
520+ var changed;
441 var before;521 var before;
522+ var third = {
523+ expectedAt: fixture.result.departures[1].expectedAt + 300,
524+ minutes: 11,
525+ status: "ON_TIME"
526+ };
527+ var fourth = {
528+ expectedAt: fixture.result.departures[1].expectedAt + 600,
529+ minutes: 16,
530+ status: "UNKNOWN"
531+ };
442 532
443- aged.fetchedAt = Math.floor(target.clock.now() / 1000) - 59;533+ seed.departures.push(third, fourth);
444 closeWith(target, configurationPayload("REPLACE", TEST_KEY));534 closeWith(target, configurationPayload("REPLACE", TEST_KEY));
445- sendRequest(target, "aged-network");535+ sendRequest(target, "visible-seed");
446- target.xhr.instances[0].respond(200, aged);536+ target.xhr.instances[0].respond(200, seed);
447- target.clock.advance(1000);537+ target.clock.advance(60000);
448- before = target.Pebble.sent.length;538+
449- sendRequest(target, "selection-refresh", contracts.REQUEST_TRIGGER.FAVORITE_SELECTION);539+ sendRequest(target, "silent-live", contracts.REQUEST_TRIGGER.MANUAL_SELECT);
450- assert.equal(
451- target.Pebble.sent.length,
452- before + fixture.result.departures.length + 3
453- );
454 assert.equal(target.xhr.instances.length, 2);540 assert.equal(target.xhr.instances.length, 2);
541+ before = messagesOfType(target, T.RESULT_BEGIN).length;
542+ unchanged = responseFor("silent-live");
543+ unchanged.departures.push(
544+ Object.assign({}, third),
545+ Object.assign({}, fourth, { expectedAt: fourth.expectedAt + 60 })
546+ );
547+ unchanged.sourceUpdatedAt += 1;
548+ unchanged.departures[0].minutes += 1;
549+ unchanged.departures[0].aimedAt += 1;
550+ target.xhr.instances[1].respond(200, unchanged);
551+ assert.equal(messagesOfType(target, T.RESULT_BEGIN).length, before);
552+
553+ sendRequest(target, "changed-live", contracts.REQUEST_TRIGGER.MANUAL_SELECT);
554+ assert.equal(target.xhr.instances.length, 3);
555+ changed = responseFor("changed-live");
556+ changed.fetchedAt += 60;
557+ target.xhr.instances[2].respond(200, changed);
558+ assert.equal(messagesOfType(target, T.RESULT_BEGIN).length, before + 1);
455 });559 });
456 560
457-test("stale cache commits before the same-request fresh result at the watch receiver", async function () {561+test("an ACKed older result remains suppressible after a later request handoff", function () {
458- var protocol = await watchProtocol();562+ var storage = configuredStorage();
459- var receiver = new protocol.ProtocolReceiver();563+ var Pebble = new fakes.FakePebble(false);
460- var target = harness();564+ var defer = fakes.createDefer(false);
461- var fresh = responseFor("cached-refresh");565+ var target = harness({ storage: storage, Pebble: Pebble, defer: defer });
566+ var changed = responseFor("ordered-first");
462 var before;567 var before;
463- var statuses;568+ var index;
464 569
465- closeWith(target, configurationPayload("REPLACE", TEST_KEY));570+ target.Pebble.emit("ready");
466- sendRequest(target, "network-seed");571+ drainQueue(target);
467- target.xhr.instances[0].respond(200, responseFor("network-seed"));572+ sendRequest(target, "ordered-first");
468- target.clock.advance(60000);573+ changed.fetchedAt += 1;
469-574+ target.xhr.instances[0].respond(200, changed);
470- assert.equal(receiver.expectResponse("cached-refresh", fixture.favorite.id), true);575+ drainQueue(target);
471- before = target.Pebble.sent.length;
472- sendRequest(target, "cached-refresh", contracts.REQUEST_TRIGGER.FAVORITE_SELECTION);
473- statuses = target.Pebble.sent.slice(before).map(function (message) {
474- return receiver.receive(watchMessage(message));
475- });
476- assert.equal(statuses.filter(function (status) {
477- return status === protocol.RECEIVE_RESULT.RESULT_COMMITTED;
478- }).length, 1);
479- assert.equal(receiver.snapshot().result.fetchedAt, fixture.result.fetchedAt);
480 576
481- fresh.fetchedAt += 60;
482 before = target.Pebble.sent.length;577 before = target.Pebble.sent.length;
483- target.xhr.instances[1].respond(200, fresh);578+ for (index = 2; index <= 50; index += 1) {
484- statuses = target.Pebble.sent.slice(before).map(function (message) {579+ sendRequest(
485- return receiver.receive(watchMessage(message));580+ target,
486- });581+ "ordered-" + index,
487- assert.equal(statuses.filter(function (status) {582+ contracts.REQUEST_TRIGGER.MANUAL_SELECT
488- return status === protocol.RECEIVE_RESULT.RESULT_COMMITTED;583+ );
489- }).length, 1);584+ }
490- assert.equal(receiver.snapshot().result.fetchedAt, fresh.fetchedAt);585+ assert.equal(target.xhr.instances.length, 1);
586+ assert.equal(target.Pebble.sent.length, before);
587+ assert.equal(target.Pebble.pending.length, 0);
588+ assert.equal(target.defer.pending.length, 0);
491 });589 });
492 590
493 test("future fetched timestamps cannot extend cache freshness past receipt time", function () {591 test("future fetched timestamps cannot extend cache freshness past receipt time", function () {
@@ -746,36 +844,313 @@ test("deleting a favorite prunes its durable result without touching config atom
746 TEST_KEY844 TEST_KEY
747 );845 );
748 });846 });
749-test("a late older response cannot overwrite or emit after the newest request for a favorite", function () {847+test("same-favorite requests share one XHR and bind a valid launch response to the latest request", function () {
750 var target = harness();848 var target = harness();
751- var older = responseFor("older");849+ var outbound;
752- var newer = responseFor("newer");
753- var beforeOlder;
754 var resultBegins;850 var resultBegins;
851+ var live = responseFor("launch-request");
755 852
756- older.fetchedAt += 1;853+ live.fetchedAt += 1;
757- newer.fetchedAt += 2;
758 closeWith(target, configurationPayload("REPLACE", TEST_KEY));854 closeWith(target, configurationPayload("REPLACE", TEST_KEY));
759- sendRequest(target, "older");855+ sendRequest(target, "launch-request");
760- sendRequest(target, "newer");856+ sendRequest(target, "latest-request", contracts.REQUEST_TRIGGER.MANUAL_SELECT);
761- assert.equal(target.xhr.instances.length, 2);857+ assert.equal(target.xhr.instances.length, 1);
858+ outbound = JSON.parse(target.xhr.instances[0].body);
859+ assert.deepEqual(outbound, {
860+ schemaVersion: contracts.SCHEMA_VERSION,
861+ requestId: "launch-request",
862+ favoriteId: fixture.favorite.id,
863+ serviceId: fixture.favorite.serviceId
864+ });
762 865
763- target.xhr.instances[1].respond(200, newer);866+ target.xhr.instances[0].respond(200, live);
764 resultBegins = messagesOfType(target, T.RESULT_BEGIN);867 resultBegins = messagesOfType(target, T.RESULT_BEGIN);
765- assert.equal(resultBegins[resultBegins.length - 1].REQUEST_ID, "newer");868+ assert.equal(resultBegins.length, 1);
766- assert.equal(resultBegins[resultBegins.length - 1].FETCHED_AT, newer.fetchedAt);869+ assert.equal(resultBegins[0].REQUEST_ID, "latest-request");
870+ assert.equal(resultBegins[0].FETCHED_AT, live.fetchedAt);
871+});
872+
873+test("same-favorite single-flight validates the immutable launch identity", function () {
874+ var target = harness();
875+ var errors;
876+
877+ closeWith(target, configurationPayload("REPLACE", TEST_KEY));
878+ sendRequest(target, "immutable-launch");
879+ sendRequest(target, "latest-binding", contracts.REQUEST_TRIGGER.FAVORITE_SELECTION);
880+ assert.equal(target.xhr.instances.length, 1);
881+ target.xhr.instances[0].respond(200, responseFor("latest-binding"));
882+
883+ assert.equal(messagesOfType(target, T.RESULT_BEGIN).length, 0);
884+ errors = messagesOfType(target, T.ERROR);
885+ assert.equal(errors.length, 1);
886+ assert.equal(errors[0].REQUEST_ID, "latest-binding");
887+ assert.equal(
888+ errors[0].ERROR_CODE,
889+ contracts.ERROR_CODE.indexOf("INVALID_RESPONSE")
890+ );
891+});
767 892
768- beforeOlder = target.Pebble.sent.length;
769- target.xhr.instances[0].respond(200, older);
770- assert.equal(target.Pebble.sent.length, beforeOlder);
771 893
772- sendRequest(target, "cache-after-race");894+test("missing, invalid, empty, and unknown favorite paths start zero XHR", function () {
895+ var missing = harness();
896+ var invalid = harness({
897+ storage: configuredStorage([fixture.favorite], contracts.KEY_STATUS.INVALID)
898+ });
899+ var empty = harness();
900+ var unknown = harness({ storage: configuredStorage() });
901+ var errors;
902+
903+ closeWith(missing, configurationPayload("REMOVE"));
904+ sendRequest(missing, "missing-key");
905+ assert.equal(missing.xhr.instances.length, 0);
906+ errors = messagesOfType(missing, T.ERROR);
907+ assert.equal(errors[0].ERROR_CODE, contracts.ERROR_CODE.indexOf("API_KEY_REQUIRED"));
908+
909+ invalid.Pebble.emit("ready");
910+ sendRequest(invalid, "invalid-key");
911+ assert.equal(invalid.xhr.instances.length, 0);
912+ errors = messagesOfType(invalid, T.ERROR);
913+ assert.equal(errors[0].ERROR_CODE, contracts.ERROR_CODE.indexOf("API_KEY_INVALID"));
914+
915+ closeWith(empty, configurationPayload("REPLACE", TEST_KEY, []));
916+ sendRequest(empty, "no-favorite");
917+ assert.equal(empty.xhr.instances.length, 0);
918+ errors = messagesOfType(empty, T.ERROR);
919+ assert.equal(errors[0].ERROR_CODE, contracts.ERROR_CODE.indexOf("INVALID_SERVICE"));
920+
921+ unknown.Pebble.emit("ready");
922+ sendRequest(unknown, "unknown-favorite", undefined, SECOND_FAVORITE);
923+ assert.equal(unknown.xhr.instances.length, 0);
924+ errors = messagesOfType(unknown, T.ERROR);
925+ assert.equal(errors[0].ERROR_CODE, contracts.ERROR_CODE.indexOf("INVALID_SERVICE"));
926+});
927+
928+test("different favorites own independent flights and inactive completion is cache-only", function () {
929+ var target = harness();
930+ var first = responseFor("a-seed");
931+ var aBegins;
932+ var before;
933+
934+ first.fetchedAt += 60;
935+ closeWith(target, configurationPayload(
936+ "REPLACE",
937+ TEST_KEY,
938+ [fixture.favorite, SECOND_FAVORITE]
939+ ));
940+ sendRequest(target, "a-seed");
941+ target.xhr.instances[0].respond(200, first);
942+ target.clock.advance(60000);
943+
944+ sendRequest(target, "a-refresh", contracts.REQUEST_TRIGGER.MANUAL_SELECT);
945+ sendRequest(
946+ target,
947+ "b-launch",
948+ contracts.REQUEST_TRIGGER.FAVORITE_SELECTION,
949+ SECOND_FAVORITE
950+ );
951+ assert.equal(target.xhr.instances.length, 3);
952+ before = messagesOfType(target, T.RESULT_BEGIN).length;
953+
954+ target.xhr.instances[1].respond(
955+ 200,
956+ contracts.copyDepartureResult(first, "a-refresh")
957+ );
958+ assert.equal(messagesOfType(target, T.RESULT_BEGIN).length, before);
959+ target.xhr.instances[2].respond(200, responseFor("b-launch", SECOND_FAVORITE));
960+
961+ sendRequest(target, "a-return", contracts.REQUEST_TRIGGER.FAVORITE_SELECTION);
962+ assert.equal(target.xhr.instances.length, 3);
963+ aBegins = messagesOfType(target, T.RESULT_BEGIN).filter(function (message) {
964+ return message.FAVORITE_ID === fixture.favorite.id;
965+ });
966+ assert.equal(aBegins[aBegins.length - 1].REQUEST_ID, "a-return");
967+ assert.equal(aBegins[aBegins.length - 1].FETCHED_AT, first.fetchedAt);
968+});
969+
970+test("an inactive favorite failure is transport-inert while the current error is sent", function () {
971+ var target = harness();
972+ var before;
973+ var errors;
974+
975+ closeWith(target, configurationPayload(
976+ "REPLACE",
977+ TEST_KEY,
978+ [fixture.favorite, SECOND_FAVORITE]
979+ ));
980+ sendRequest(target, "inactive-failure");
981+ sendRequest(
982+ target,
983+ "current-failure",
984+ contracts.REQUEST_TRIGGER.FAVORITE_SELECTION,
985+ SECOND_FAVORITE
986+ );
773 assert.equal(target.xhr.instances.length, 2);987 assert.equal(target.xhr.instances.length, 2);
774- resultBegins = messagesOfType(target, T.RESULT_BEGIN);988+ before = target.Pebble.sent.length;
775- assert.equal(resultBegins[resultBegins.length - 1].REQUEST_ID, "cache-after-race");989+ target.xhr.instances[0].networkError();
776- assert.equal(resultBegins[resultBegins.length - 1].FETCHED_AT, newer.fetchedAt);990+ assert.equal(target.Pebble.sent.length, before);
991+
992+ target.xhr.instances[1].networkError();
993+ errors = messagesOfType(target, T.ERROR);
994+ assert.equal(errors.length, 1);
995+ assert.equal(errors[0].REQUEST_ID, "current-failure");
996+ assert.equal(errors[0].FAVORITE_ID, SECOND_FAVORITE.id);
997+});
998+
999+test("visible signature changes each enqueue one atomic replacement", function () {
1000+ var cases = [
1001+ {
1002+ name: "fetchedAt",
1003+ change: function (result) { result.fetchedAt += 60; }
1004+ },
1005+ {
1006+ name: "freshness",
1007+ change: function (result) { result.freshness = "SCHEDULED"; }
1008+ },
1009+ {
1010+ name: "expectedAt",
1011+ change: function (result) { result.departures[0].expectedAt += 1; }
1012+ },
1013+ {
1014+ name: "status",
1015+ change: function (result) { result.departures[0].status = "CANCELLED"; }
1016+ },
1017+ {
1018+ name: "nextIntervalMinutes",
1019+ change: function (result) { delete result.departures[0].nextIntervalMinutes; }
1020+ }
1021+ ];
1022+
1023+ cases.forEach(function (item, index) {
1024+ var target = harness();
1025+ var seed = responseFor("signature-seed-" + index);
1026+ var replacement;
1027+ var before;
1028+ closeWith(target, configurationPayload("REPLACE", TEST_KEY));
1029+ sendRequest(target, "signature-seed-" + index);
1030+ target.xhr.instances[0].respond(200, seed);
1031+ target.clock.advance(60000);
1032+ sendRequest(target, "signature-live-" + index);
1033+ before = messagesOfType(target, T.RESULT_BEGIN).length;
1034+ replacement = responseFor("signature-live-" + index);
1035+ item.change(replacement);
1036+ target.xhr.instances[1].respond(200, replacement);
1037+ assert.equal(
1038+ messagesOfType(target, T.RESULT_BEGIN).length,
1039+ before + 1,
1040+ item.name
1041+ );
1042+ });
777 });1043 });
778 1044
1045+test("delayed ACKs retain only one latest-bound same-signature replacement", function () {
1046+ var storage = configuredStorage();
1047+ var clock = new fakes.FakeClock();
1048+ var Pebble = new fakes.FakePebble(false);
1049+ var defer = fakes.createDefer(false);
1050+ var stored = responseFor("coalesced-stored");
1051+ var target;
1052+ var baseline;
1053+ var latestRequestId = null;
1054+ var sent;
1055+ var batchLength;
1056+ var index;
1057+
1058+ stored.fetchedAt -= contracts.CACHE_FRESH_SECONDS;
1059+ storeResult(storage, stored, clock.now());
1060+ target = harness({ storage: storage, clock: clock, Pebble: Pebble, defer: defer });
1061+ target.Pebble.emit("ready");
1062+ drainQueue(target);
1063+ baseline = target.Pebble.sent.length;
1064+
1065+ sendRequest(target, "coalesced-1");
1066+ for (index = 2; index <= 50; index += 1) {
1067+ latestRequestId = "coalesced-" + index;
1068+ sendRequest(
1069+ target,
1070+ latestRequestId,
1071+ contracts.REQUEST_TRIGGER.MANUAL_SELECT
1072+ );
1073+ }
1074+ assert.equal(target.xhr.instances.length, 1);
1075+ assert.equal(target.Pebble.sent.length, baseline + 1);
1076+
1077+ drainQueue(target);
1078+ sent = target.Pebble.sent.slice(baseline);
1079+ batchLength = stored.departures.length + 3;
1080+ assert.equal(sent.length, batchLength * 2);
1081+ assert.equal(sent.slice(0, batchLength).every(function (message) {
1082+ return message.REQUEST_ID === "coalesced-1";
1083+ }), true);
1084+ assert.equal(sent.slice(batchLength).every(function (message) {
1085+ return message.REQUEST_ID === latestRequestId;
1086+ }), true);
1087+ assert.deepEqual(sent.map(function (message) {
1088+ return message.MESSAGE_TYPE;
1089+ }), [
1090+ T.RESULT_BEGIN,
1091+ T.DEPARTURE,
1092+ T.DEPARTURE,
1093+ T.RESULT_COMMIT,
1094+ T.REQUEST,
1095+ T.RESULT_BEGIN,
1096+ T.DEPARTURE,
1097+ T.DEPARTURE,
1098+ T.RESULT_COMMIT,
1099+ T.REQUEST
1100+ ]);
1101+ assert.equal(target.Pebble.maxInFlight, 1);
1102+ assert.equal(target.Pebble.pending.length, 0);
1103+ assert.equal(target.defer.pending.length, 0);
1104+});
1105+
1106+test("queue NACK clears superseded result batches without retrying or duplicating XHR", function () {
1107+ var storage = configuredStorage();
1108+ var clock = new fakes.FakeClock();
1109+ var Pebble = new fakes.FakePebble(false);
1110+ var defer = fakes.createDefer(false);
1111+ var stored = responseFor("stored-pending");
1112+ var target;
1113+ var before;
1114+
1115+ stored.fetchedAt -= 60;
1116+ storeResult(storage, stored, clock.now());
1117+ target = harness({ storage: storage, clock: clock, Pebble: Pebble, defer: defer });
1118+ target.Pebble.emit("ready");
1119+ drainQueue(target);
1120+
1121+ sendRequest(target, "pending-first");
1122+ assert.equal(target.xhr.instances.length, 1);
1123+ before = target.Pebble.sent.length;
1124+ sendRequest(target, "pending-latest", contracts.REQUEST_TRIGGER.MANUAL_SELECT);
1125+ assert.equal(target.Pebble.sent.length, before);
1126+ assert.equal(target.xhr.instances.length, 1);
1127+
1128+ target.Pebble.fail();
1129+ assert.equal(target.Pebble.sent.length, before);
1130+ assert.equal(target.defer.pending.length, 0);
1131+ sendRequest(target, "pending-after-nack");
1132+ assert.equal(target.Pebble.sent.length, before + 1);
1133+ assert.equal(target.xhr.instances.length, 1);
1134+});
1135+
1136+test("accepted configuration changes abort flights and make late callbacks inert", function () {
1137+ var target = harness();
1138+ var flight;
1139+ var before;
1140+
1141+ closeWith(target, configurationPayload("REPLACE", TEST_KEY));
1142+ sendRequest(target, "before-config-change");
1143+ flight = target.xhr.instances[0];
1144+ flight.abort = function () { this.aborted = true; };
1145+ closeWith(target, configurationPayload("KEEP"));
1146+ assert.equal(flight.aborted, true);
1147+
1148+ before = target.Pebble.sent.length;
1149+ flight.respond(200, responseFor("before-config-change"));
1150+ assert.equal(target.Pebble.sent.length, before);
1151+ sendRequest(target, "after-config-change");
1152+ assert.equal(target.xhr.instances.length, 2);
1153+});
779 1154
780 test("HTTP failures map to stable error codes, keyStatus only, and never retry", function () {1155 test("HTTP failures map to stable error codes, keyStatus only, and never retry", function () {
781 var cases = [1156 var cases = [
@@ -820,25 +1195,62 @@ test("HTTP failures map to stable error codes, keyStatus only, and never retry",
820 });1195 });
821 });1196 });
822 1197
823-test("network failure emits one sanitized error and schedules no retry", function () {1198+test("network failure emits one latest-bound sanitized error without retry", function () {
824 var target = harness();1199 var target = harness();
825 var errors;1200 var errors;
826 closeWith(target, configurationPayload("REPLACE", TEST_KEY));1201 closeWith(target, configurationPayload("REPLACE", TEST_KEY));
827- sendRequest(target, "network-error");1202+ sendRequest(target, "network-launch");
1203+ sendRequest(target, "network-latest", contracts.REQUEST_TRIGGER.MANUAL_SELECT);
1204+ assert.equal(target.xhr.instances.length, 1);
828 target.xhr.instances[0].networkError();1205 target.xhr.instances[0].networkError();
829 errors = messagesOfType(target, T.ERROR);1206 errors = messagesOfType(target, T.ERROR);
830 assert.equal(errors.length, 1);1207 assert.equal(errors.length, 1);
1208+ assert.equal(errors[0].REQUEST_ID, "network-latest");
831 assert.equal(errors[0].ERROR_CODE, contracts.ERROR_CODE.indexOf("SOURCE_UNAVAILABLE"));1209 assert.equal(errors[0].ERROR_CODE, contracts.ERROR_CODE.indexOf("SOURCE_UNAVAILABLE"));
832 assert.equal(target.xhr.instances.length, 1);1210 assert.equal(target.xhr.instances.length, 1);
833 assert.equal(target.clock.timers.length, 0);1211 assert.equal(target.clock.timers.length, 0);
834 assert.equal(JSON.stringify(errors).includes(TEST_KEY), false);1212 assert.equal(JSON.stringify(errors).includes(TEST_KEY), false);
835 });1213 });
836-test("build bootstrap validates URLs and injects only the zero-delay ACK defer", async function () {1214+
1215+test("throwing XHR constructor emits one current sanitized error without retry", function () {
1216+ var target;
1217+ var errors;
1218+
1219+ function ThrowingXHR() {
1220+ throw new Error("constructor failure " + TEST_KEY);
1221+ }
1222+
1223+ target = harness({
1224+ storage: configuredStorage(),
1225+ XHR: ThrowingXHR
1226+ });
1227+ sendRequest(target, "constructor-failure", contracts.REQUEST_TRIGGER.MANUAL_SELECT);
1228+
1229+ errors = messagesOfType(target, T.ERROR);
1230+ assert.equal(errors.length, 1);
1231+ assert.equal(errors[0].REQUEST_ID, "constructor-failure");
1232+ assert.equal(errors[0].FAVORITE_ID, fixture.favorite.id);
1233+ assert.equal(
1234+ errors[0].ERROR_CODE,
1235+ contracts.ERROR_CODE.indexOf("SOURCE_UNAVAILABLE")
1236+ );
1237+ assert.equal(target.xhr.instances.length, 0);
1238+ assert.equal(target.companion.metrics().requests, 0);
1239+ assert.equal(target.companion.metrics().failures, 1);
1240+ assert.equal(target.clock.timers.length, 0);
1241+ assert.equal(JSON.stringify(errors).includes(TEST_KEY), false);
1242+
1243+ target.clock.advance(3600000);
1244+ assert.equal(messagesOfType(target, T.ERROR).length, 1);
1245+});
1246+
1247+test("build bootstrap keeps ACK and one-shot ready settling schedulers distinct", async function () {
837 var build = await import("../../../scripts/build.mjs");1248 var build = await import("../../../scripts/build.mjs");
838 var configured;1249 var configured;
839 var captured = null;1250 var captured = null;
840 var scheduled = [];1251 var scheduled = [];
841 var deferred = false;1252 var deferred = false;
1253+ var readyDeferred = false;
842 var source;1254 var source;
843 1255
844 assert.deepEqual(build.readBuildConfiguration(Object.create(null)), {1256 assert.deepEqual(build.readBuildConfiguration(Object.create(null)), {
@@ -859,7 +1271,7 @@ test("build bootstrap validates URLs and injects only the zero-delay ACK defer",
859 LAPIN_FUTE_BACKEND_URL: "https://backend.example.test/departures",1271 LAPIN_FUTE_BACKEND_URL: "https://backend.example.test/departures",
860 LAPIN_FUTE_CONFIG_URL: "https://config.example.test/index.html"1272 LAPIN_FUTE_CONFIG_URL: "https://config.example.test/index.html"
861 });1273 });
862- source = build.createBootstrap(fixture, configured);1274+ source = build.createBootstrap(configured);
863 vm.runInNewContext(source, {1275 vm.runInNewContext(source, {
864 Pebble: {},1276 Pebble: {},
865 localStorage: {},1277 localStorage: {},
@@ -881,14 +1293,29 @@ test("build bootstrap validates URLs and injects only the zero-delay ACK defer",
881 assert.notEqual(captured, null);1293 assert.notEqual(captured, null);
882 assert.deepEqual(Object.keys(captured.clock), ["now"]);1294 assert.deepEqual(Object.keys(captured.clock), ["now"]);
883 captured.defer(function () { deferred = true; });1295 captured.defer(function () { deferred = true; });
1296+ captured.readyDefer(function () { readyDeferred = true; });
884 assert.equal(deferred, false);1297 assert.equal(deferred, false);
885- assert.equal(scheduled.length, 1);1298+ assert.equal(readyDeferred, false);
1299+ assert.equal(scheduled.length, 2);
886 assert.equal(scheduled[0].delay, 0);1300 assert.equal(scheduled[0].delay, 0);
1301+ assert.equal(scheduled[1].delay, 250);
887 scheduled[0].callback();1302 scheduled[0].callback();
1303+ scheduled[1].callback();
888 assert.equal(deferred, true);1304 assert.equal(deferred, true);
1305+ assert.equal(readyDeferred, true);
889 assert.equal(captured.backendUrl, configured.backendUrl);1306 assert.equal(captured.backendUrl, configured.backendUrl);
890 assert.equal(captured.configurationUrl, configured.configurationUrl);1307 assert.equal(captured.configurationUrl, configured.configurationUrl);
891- assert.equal(JSON.stringify(captured.fixture), JSON.stringify(fixture));1308+ assert.deepEqual(Object.keys(captured), [
1309+ "Pebble",
1310+ "storage",
1311+ "XHR",
1312+ "clock",
1313+ "defer",
1314+ "readyDefer",
1315+ "backendUrl",
1316+ "configurationUrl"
1317+ ]);
1318+ assert.equal(source.includes("fixture"), false);
892 assert.equal(source.includes("LAPIN_FUTE_"), false);1319 assert.equal(source.includes("LAPIN_FUTE_"), false);
893 assert.equal(source.includes(TEST_KEY), false);1320 assert.equal(source.includes(TEST_KEY), false);
894 });1321 });
modified packages/companion/test/protocol.test.js +41 -6
@@ -124,31 +124,38 @@ test("configuration encoder requires a normalized contextual language", function
124124 );
125125 });
126126
127-test("callback queue yields after each ACK and drains whole batches in FIFO order", function () {
127+test("callback queue completes batches only after their final ACK and preserves FIFO deferral", function () {
128128 var Pebble = new fakes.FakePebble(false);
129129 var defer = fakes.createDefer(false);
130130 var failures = [];
131+ var completions = [];
131132 var queue = new companion.MessageQueue(Pebble, defer, function (code) { failures.push(code); });
132133
133- queue.enqueue([{ id: "batch-a-1" }, { id: "batch-a-2" }]);
134- queue.enqueue([{ id: "batch-b-1" }]);
134+ queue.enqueue(
135+ [{ id: "batch-a-1" }, { id: "batch-a-2" }],
136+ function () { completions.push("a"); }
137+ );
138+ queue.enqueue([{ id: "batch-b-1" }], function () { completions.push("b"); });
135139 assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), ["batch-a-1"]);
136140 assert.equal(Pebble.maxInFlight, 1);
137141
138142 Pebble.ack();
143+ assert.deepEqual(completions, []);
139144 assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), ["batch-a-1"]);
140145 assert.equal(defer.pending.length, 1);
141- queue.enqueue([{ id: "batch-c-1" }]);
146+ queue.enqueue([{ id: "batch-c-1" }], function () { completions.push("c"); });
142147 assert.equal(defer.pending.length, 1);
143148
144149 defer.runNext();
145150 Pebble.ack();
151+ assert.deepEqual(completions, ["a"]);
146152 assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), [
147153 "batch-a-1",
148154 "batch-a-2"
149155 ]);
150156 defer.runNext();
151157 Pebble.ack();
158+ assert.deepEqual(completions, ["a", "b"]);
152159 defer.runNext();
153160 Pebble.ack();
154161
@@ -158,6 +165,7 @@ test("callback queue yields after each ACK and drains whole batches in FIFO orde
158165 "batch-b-1",
159166 "batch-c-1"
160167 ]);
168+ assert.deepEqual(completions, ["a", "b", "c"]);
161169 assert.equal(defer.pending.length, 0);
162170 assert.equal(queue.isSending(), false);
163171 assert.deepEqual(failures, []);
@@ -205,16 +213,43 @@ test("a stale failure cannot clear or fail a newer queue generation", function (
205213 assert.deepEqual(failures, []);
206214 });
207215
208-test("failed AppMessage is dropped without retry or sequence advancement", function () {
216+test("failed AppMessage drops the batch without retry or completion", function () {
209217 var Pebble = new fakes.FakePebble(false);
210218 var defer = fakes.createDefer(false);
211219 var failures = [];
220+ var completions = [];
212221 var queue = new companion.MessageQueue(Pebble, defer, function (code) { failures.push(code); });
213222
214- queue.enqueue([{ SCHEMA_VERSION: 1 }, { MESSAGE_TYPE: 2 }]);
223+ queue.enqueue(
224+ [{ SCHEMA_VERSION: 1 }, { MESSAGE_TYPE: 2 }],
225+ function () { completions.push("complete"); }
226+ );
215227 Pebble.fail();
216228 assert.equal(Pebble.sent.length, 1);
217229 assert.equal(defer.pending.length, 0);
218230 assert.deepEqual(failures, ["APP_MESSAGE_FAILED"]);
231+ assert.deepEqual(completions, []);
232+ assert.equal(queue.isSending(), false);
233+});
234+
235+test("synchronous AppMessage failure drops the batch and reports one failure", function () {
236+ var Pebble = new fakes.FakePebble(false);
237+ var defer = fakes.createDefer(false);
238+ var failures = [];
239+ var completions = [];
240+ var queue;
241+ Pebble.sendAppMessage = function (message) {
242+ this.sent.push(message);
243+ throw new Error("synchronous send failure");
244+ };
245+ queue = new companion.MessageQueue(Pebble, defer, function (code) {
246+ failures.push(code);
247+ });
248+
249+ queue.enqueue([{ id: "throws" }], function () { completions.push("complete"); });
250+ assert.deepEqual(Pebble.sent, [{ id: "throws" }]);
251+ assert.deepEqual(failures, ["APP_MESSAGE_FAILED"]);
252+ assert.deepEqual(completions, []);
219253 assert.equal(queue.isSending(), false);
254+ assert.equal(defer.pending.length, 0);
220255 });
@@ -124,31 +124,38 @@ test("configuration encoder requires a normalized contextual language", function
124 );124 );
125 });125 });
126 126
127-test("callback queue yields after each ACK and drains whole batches in FIFO order", function () {127+test("callback queue completes batches only after their final ACK and preserves FIFO deferral", function () {
128 var Pebble = new fakes.FakePebble(false);128 var Pebble = new fakes.FakePebble(false);
129 var defer = fakes.createDefer(false);129 var defer = fakes.createDefer(false);
130 var failures = [];130 var failures = [];
131+ var completions = [];
131 var queue = new companion.MessageQueue(Pebble, defer, function (code) { failures.push(code); });132 var queue = new companion.MessageQueue(Pebble, defer, function (code) { failures.push(code); });
132 133
133- queue.enqueue([{ id: "batch-a-1" }, { id: "batch-a-2" }]);134+ queue.enqueue(
134- queue.enqueue([{ id: "batch-b-1" }]);135+ [{ id: "batch-a-1" }, { id: "batch-a-2" }],
136+ function () { completions.push("a"); }
137+ );
138+ queue.enqueue([{ id: "batch-b-1" }], function () { completions.push("b"); });
135 assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), ["batch-a-1"]);139 assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), ["batch-a-1"]);
136 assert.equal(Pebble.maxInFlight, 1);140 assert.equal(Pebble.maxInFlight, 1);
137 141
138 Pebble.ack();142 Pebble.ack();
143+ assert.deepEqual(completions, []);
139 assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), ["batch-a-1"]);144 assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), ["batch-a-1"]);
140 assert.equal(defer.pending.length, 1);145 assert.equal(defer.pending.length, 1);
141- queue.enqueue([{ id: "batch-c-1" }]);146+ queue.enqueue([{ id: "batch-c-1" }], function () { completions.push("c"); });
142 assert.equal(defer.pending.length, 1);147 assert.equal(defer.pending.length, 1);
143 148
144 defer.runNext();149 defer.runNext();
145 Pebble.ack();150 Pebble.ack();
151+ assert.deepEqual(completions, ["a"]);
146 assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), [152 assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), [
147 "batch-a-1",153 "batch-a-1",
148 "batch-a-2"154 "batch-a-2"
149 ]);155 ]);
150 defer.runNext();156 defer.runNext();
151 Pebble.ack();157 Pebble.ack();
158+ assert.deepEqual(completions, ["a", "b"]);
152 defer.runNext();159 defer.runNext();
153 Pebble.ack();160 Pebble.ack();
154 161
@@ -158,6 +165,7 @@ test("callback queue yields after each ACK and drains whole batches in FIFO orde
158 "batch-b-1",165 "batch-b-1",
159 "batch-c-1"166 "batch-c-1"
160 ]);167 ]);
168+ assert.deepEqual(completions, ["a", "b", "c"]);
161 assert.equal(defer.pending.length, 0);169 assert.equal(defer.pending.length, 0);
162 assert.equal(queue.isSending(), false);170 assert.equal(queue.isSending(), false);
163 assert.deepEqual(failures, []);171 assert.deepEqual(failures, []);
@@ -205,16 +213,43 @@ test("a stale failure cannot clear or fail a newer queue generation", function (
205 assert.deepEqual(failures, []);213 assert.deepEqual(failures, []);
206 });214 });
207 215
208-test("failed AppMessage is dropped without retry or sequence advancement", function () {216+test("failed AppMessage drops the batch without retry or completion", function () {
209 var Pebble = new fakes.FakePebble(false);217 var Pebble = new fakes.FakePebble(false);
210 var defer = fakes.createDefer(false);218 var defer = fakes.createDefer(false);
211 var failures = [];219 var failures = [];
220+ var completions = [];
212 var queue = new companion.MessageQueue(Pebble, defer, function (code) { failures.push(code); });221 var queue = new companion.MessageQueue(Pebble, defer, function (code) { failures.push(code); });
213 222
214- queue.enqueue([{ SCHEMA_VERSION: 1 }, { MESSAGE_TYPE: 2 }]);223+ queue.enqueue(
224+ [{ SCHEMA_VERSION: 1 }, { MESSAGE_TYPE: 2 }],
225+ function () { completions.push("complete"); }
226+ );
215 Pebble.fail();227 Pebble.fail();
216 assert.equal(Pebble.sent.length, 1);228 assert.equal(Pebble.sent.length, 1);
217 assert.equal(defer.pending.length, 0);229 assert.equal(defer.pending.length, 0);
218 assert.deepEqual(failures, ["APP_MESSAGE_FAILED"]);230 assert.deepEqual(failures, ["APP_MESSAGE_FAILED"]);
231+ assert.deepEqual(completions, []);
232+ assert.equal(queue.isSending(), false);
233+});
234+
235+test("synchronous AppMessage failure drops the batch and reports one failure", function () {
236+ var Pebble = new fakes.FakePebble(false);
237+ var defer = fakes.createDefer(false);
238+ var failures = [];
239+ var completions = [];
240+ var queue;
241+ Pebble.sendAppMessage = function (message) {
242+ this.sent.push(message);
243+ throw new Error("synchronous send failure");
244+ };
245+ queue = new companion.MessageQueue(Pebble, defer, function (code) {
246+ failures.push(code);
247+ });
248+
249+ queue.enqueue([{ id: "throws" }], function () { completions.push("complete"); });
250+ assert.deepEqual(Pebble.sent, [{ id: "throws" }]);
251+ assert.deepEqual(failures, ["APP_MESSAGE_FAILED"]);
252+ assert.deepEqual(completions, []);
219 assert.equal(queue.isSending(), false);253 assert.equal(queue.isSending(), false);
254+ assert.equal(defer.pending.length, 0);
220 });255 });
modified packages/watch/src/c/mdbl.c +3 -3
@@ -1,8 +1,8 @@
11 #include <pebble.h>
22
3-#define XS_STACK_BYTES (4U * 1024U)
4-#define XS_SLOT_BYTES (32U * 1024U)
5-#define XS_CHUNK_BYTES (20U * 1024U)
3+#define XS_STACK_BYTES (7U * 512U)
4+#define XS_SLOT_BYTES (77U * 512U)
5+#define XS_CHUNK_BYTES (14U * 1024U)
66 #define XS_ARENA_TOTAL_BYTES \
77 (XS_STACK_BYTES + XS_SLOT_BYTES + XS_CHUNK_BYTES)
88 #define XS_ARENA_BUDGET_BYTES (56U * 1024U)
@@ -1,8 +1,8 @@
1 #include <pebble.h>1 #include <pebble.h>
2 2
3-#define XS_STACK_BYTES (4U * 1024U)3+#define XS_STACK_BYTES (7U * 512U)
4-#define XS_SLOT_BYTES (32U * 1024U)4+#define XS_SLOT_BYTES (77U * 512U)
5-#define XS_CHUNK_BYTES (20U * 1024U)5+#define XS_CHUNK_BYTES (14U * 1024U)
6 #define XS_ARENA_TOTAL_BYTES \6 #define XS_ARENA_TOTAL_BYTES \
7 (XS_STACK_BYTES + XS_SLOT_BYTES + XS_CHUNK_BYTES)7 (XS_STACK_BYTES + XS_SLOT_BYTES + XS_CHUNK_BYTES)
8 #define XS_ARENA_BUDGET_BYTES (56U * 1024U)8 #define XS_ARENA_BUDGET_BYTES (56U * 1024U)
modified packages/watch/src/embeddedjs/contracts.js +30 -60
@@ -1,5 +1,8 @@
11 export const SCHEMA_VERSION = 1;
22
3+export const APP_MESSAGE_INBOX_BYTES = 768;
4+export const APP_MESSAGE_OUTBOX_BYTES = 192;
5+
36 export const LIMITS = Object.freeze({
47 idUtf8Bytes: 64,
58 labelUtf8Bytes: 96,
@@ -55,67 +58,34 @@ export const MESSAGE_TYPE = Object.freeze({
5558 ERROR: 8
5659 });
5760
58-export const APP_MESSAGE_KEY_ORDER = Object.freeze([
59- "SCHEMA_VERSION",
60- "MESSAGE_TYPE",
61- "REQUEST_ID",
62- "FAVORITE_ID",
63- "SERVICE_ID",
64- "DISPLAY_NAME",
65- "STOP_LABEL",
66- "LINE_LABEL",
67- "DESTINATION_LABEL",
68- "SORT_ORDER",
69- "KEY_STATUS",
70- "ITEM_COUNT",
71- "ITEM_INDEX",
72- "FETCHED_AT",
73- "SOURCE_UPDATED_AT",
74- "FRESHNESS",
75- "EXPECTED_AT",
76- "AIMED_AT",
77- "MINUTES",
78- "DEPARTURE_STATUS",
79- "NEXT_INTERVAL_MINUTES",
80- "ERROR_CODE",
81- "OCCURRED_AT",
82- "RETRY_AFTER_SECONDS",
83- "REQUEST_TRIGGER"
84-]);
85-
86-export const APP_MESSAGE_KEY = Object.freeze({
87- SCHEMA_VERSION: 0,
88- MESSAGE_TYPE: 1,
89- REQUEST_ID: 2,
90- FAVORITE_ID: 3,
91- SERVICE_ID: 4,
92- DISPLAY_NAME: 5,
93- STOP_LABEL: 6,
94- LINE_LABEL: 7,
95- DESTINATION_LABEL: 8,
96- SORT_ORDER: 9,
97- KEY_STATUS: 10,
98- ITEM_COUNT: 11,
99- ITEM_INDEX: 12,
100- FETCHED_AT: 13,
101- SOURCE_UPDATED_AT: 14,
102- FRESHNESS: 15,
103- EXPECTED_AT: 16,
104- AIMED_AT: 17,
105- MINUTES: 18,
106- DEPARTURE_STATUS: 19,
107- NEXT_INTERVAL_MINUTES: 20,
108- ERROR_CODE: 21,
109- OCCURRED_AT: 22,
110- RETRY_AFTER_SECONDS: 23,
111- REQUEST_TRIGGER: 24
112-});
11361 // SDK 4.33.1 auto-numbers array-form Message keys from 10000.
114-export const APP_MESSAGE_KEY_MAP = new Map();
115-for (let index = 0; index < APP_MESSAGE_KEY_ORDER.length; index += 1) {
116- const alias = APP_MESSAGE_KEY_ORDER[index];
117- APP_MESSAGE_KEY_MAP.set(alias, APP_MESSAGE_KEY[alias]);
118-}
62+export const APP_MESSAGE_KEY_MAP = new Map([
63+ ["SCHEMA_VERSION", 0],
64+ ["MESSAGE_TYPE", 1],
65+ ["REQUEST_ID", 2],
66+ ["FAVORITE_ID", 3],
67+ ["SERVICE_ID", 4],
68+ ["DISPLAY_NAME", 5],
69+ ["STOP_LABEL", 6],
70+ ["LINE_LABEL", 7],
71+ ["DESTINATION_LABEL", 8],
72+ ["SORT_ORDER", 9],
73+ ["KEY_STATUS", 10],
74+ ["ITEM_COUNT", 11],
75+ ["ITEM_INDEX", 12],
76+ ["FETCHED_AT", 13],
77+ ["SOURCE_UPDATED_AT", 14],
78+ ["FRESHNESS", 15],
79+ ["EXPECTED_AT", 16],
80+ ["AIMED_AT", 17],
81+ ["MINUTES", 18],
82+ ["DEPARTURE_STATUS", 19],
83+ ["NEXT_INTERVAL_MINUTES", 20],
84+ ["ERROR_CODE", 21],
85+ ["OCCURRED_AT", 22],
86+ ["RETRY_AFTER_SECONDS", 23],
87+ ["REQUEST_TRIGGER", 24]
88+]);
11989
12090 export const LANGUAGE = Object.freeze({ EN: "en", FR: "fr" });
12191
@@ -1,5 +1,8 @@
1 export const SCHEMA_VERSION = 1;1 export const SCHEMA_VERSION = 1;
2 2
3+export const APP_MESSAGE_INBOX_BYTES = 768;
4+export const APP_MESSAGE_OUTBOX_BYTES = 192;
5+
3 export const LIMITS = Object.freeze({6 export const LIMITS = Object.freeze({
4 idUtf8Bytes: 64,7 idUtf8Bytes: 64,
5 labelUtf8Bytes: 96,8 labelUtf8Bytes: 96,
@@ -55,67 +58,34 @@ export const MESSAGE_TYPE = Object.freeze({
55 ERROR: 858 ERROR: 8
56 });59 });
57 60
58-export const APP_MESSAGE_KEY_ORDER = Object.freeze([
59- "SCHEMA_VERSION",
60- "MESSAGE_TYPE",
61- "REQUEST_ID",
62- "FAVORITE_ID",
63- "SERVICE_ID",
64- "DISPLAY_NAME",
65- "STOP_LABEL",
66- "LINE_LABEL",
67- "DESTINATION_LABEL",
68- "SORT_ORDER",
69- "KEY_STATUS",
70- "ITEM_COUNT",
71- "ITEM_INDEX",
72- "FETCHED_AT",
73- "SOURCE_UPDATED_AT",
74- "FRESHNESS",
75- "EXPECTED_AT",
76- "AIMED_AT",
77- "MINUTES",
78- "DEPARTURE_STATUS",
79- "NEXT_INTERVAL_MINUTES",
80- "ERROR_CODE",
81- "OCCURRED_AT",
82- "RETRY_AFTER_SECONDS",
83- "REQUEST_TRIGGER"
84-]);
85-
86-export const APP_MESSAGE_KEY = Object.freeze({
87- SCHEMA_VERSION: 0,
88- MESSAGE_TYPE: 1,
89- REQUEST_ID: 2,
90- FAVORITE_ID: 3,
91- SERVICE_ID: 4,
92- DISPLAY_NAME: 5,
93- STOP_LABEL: 6,
94- LINE_LABEL: 7,
95- DESTINATION_LABEL: 8,
96- SORT_ORDER: 9,
97- KEY_STATUS: 10,
98- ITEM_COUNT: 11,
99- ITEM_INDEX: 12,
100- FETCHED_AT: 13,
101- SOURCE_UPDATED_AT: 14,
102- FRESHNESS: 15,
103- EXPECTED_AT: 16,
104- AIMED_AT: 17,
105- MINUTES: 18,
106- DEPARTURE_STATUS: 19,
107- NEXT_INTERVAL_MINUTES: 20,
108- ERROR_CODE: 21,
109- OCCURRED_AT: 22,
110- RETRY_AFTER_SECONDS: 23,
111- REQUEST_TRIGGER: 24
112-});
113 // SDK 4.33.1 auto-numbers array-form Message keys from 10000.61 // SDK 4.33.1 auto-numbers array-form Message keys from 10000.
114-export const APP_MESSAGE_KEY_MAP = new Map();62+export const APP_MESSAGE_KEY_MAP = new Map([
115-for (let index = 0; index < APP_MESSAGE_KEY_ORDER.length; index += 1) {63+ ["SCHEMA_VERSION", 0],
116- const alias = APP_MESSAGE_KEY_ORDER[index];64+ ["MESSAGE_TYPE", 1],
117- APP_MESSAGE_KEY_MAP.set(alias, APP_MESSAGE_KEY[alias]);65+ ["REQUEST_ID", 2],
118-}66+ ["FAVORITE_ID", 3],
67+ ["SERVICE_ID", 4],
68+ ["DISPLAY_NAME", 5],
69+ ["STOP_LABEL", 6],
70+ ["LINE_LABEL", 7],
71+ ["DESTINATION_LABEL", 8],
72+ ["SORT_ORDER", 9],
73+ ["KEY_STATUS", 10],
74+ ["ITEM_COUNT", 11],
75+ ["ITEM_INDEX", 12],
76+ ["FETCHED_AT", 13],
77+ ["SOURCE_UPDATED_AT", 14],
78+ ["FRESHNESS", 15],
79+ ["EXPECTED_AT", 16],
80+ ["AIMED_AT", 17],
81+ ["MINUTES", 18],
82+ ["DEPARTURE_STATUS", 19],
83+ ["NEXT_INTERVAL_MINUTES", 20],
84+ ["ERROR_CODE", 21],
85+ ["OCCURRED_AT", 22],
86+ ["RETRY_AFTER_SECONDS", 23],
87+ ["REQUEST_TRIGGER", 24]
88+]);
119 89
120 export const LANGUAGE = Object.freeze({ EN: "en", FR: "fr" });90 export const LANGUAGE = Object.freeze({ EN: "en", FR: "fr" });
121 91
modified packages/watch/src/embeddedjs/controller.js +149 -46
@@ -1,4 +1,5 @@
11 import { QUEUE_STATE } from "./message-queue.js";
2+import { LIMITS, REQUEST_TRIGGER } from "./contracts.js";
23 import { ProtocolReceiver, RECEIVE_RESULT, encodeRequest } from "./protocol.js";
34 import { loadWatchConfiguration, saveWatchConfiguration } from "./storage.js";
45
@@ -15,6 +16,9 @@ export function createController(options) {
1516 || typeof options.clock.now !== "function"
1617 || !options.queue
1718 || typeof options.queue.enqueue !== "function"
19+ || !options.scheduler
20+ || typeof options.scheduler.set !== "function"
21+ || typeof options.scheduler.clear !== "function"
1822 || !options.model
1923 || typeof options.model.snapshot !== "function"
2024 || !options.view
@@ -24,95 +28,191 @@ export function createController(options) {
2428
2529 const clock = options.clock;
2630 const queue = options.queue;
31+ const scheduler = options.scheduler;
2732 const model = options.model;
2833 const storage = options.storage || null;
2934 const view = options.view;
3035 const receiver = new ProtocolReceiver();
31- let committedConfiguration = null;
36+ const requestPrefix = "consult-"
37+ + Math.max(0, Math.floor(clock.now() / 1000)).toString(36)
38+ + "-";
39+ let committedProtocolState = null;
3240 let sequence = 0;
33- let restoredRequestPending = false;
41+ let selectionGeneration = 0;
42+ let selectionTimer = null;
43+ let transportFailureGeneration = 0;
44+ let configurationGeneration = 0;
45+ let configurationTimer = null;
46+ let appOpenRequested = false;
3447 let started = false;
3548 let closed = false;
49+ let active = true;
3650
3751 function render() {
38- view.render(model.snapshot());
52+ const snapshot = model.snapshot(clock.now(), false);
53+ view.render(snapshot);
54+ return snapshot;
3955 }
4056
41- function nextRequestId() {
42- sequence += 1;
43- if (sequence > 1679615) sequence = 1;
44- const seconds = Math.max(0, Math.floor(clock.now() / 1000));
45- return "foundation-" + seconds.toString(36) + "-" + sequence.toString(36);
57+ function invalidateSelectionTimer() {
58+ selectionGeneration += 1;
59+ if (selectionTimer !== null) scheduler.clear(selectionTimer);
60+ selectionTimer = null;
61+ }
62+
63+ function invalidateConfigurationTimer() {
64+ configurationGeneration += 1;
65+ if (configurationTimer !== null) scheduler.clear(configurationTimer);
66+ configurationTimer = null;
67+ }
68+
69+ function failRequest() {
70+ receiver.cancelExpectedResponse();
71+ model.cancelRequest();
72+ model.markSendFailure();
73+ render();
4674 }
4775
48- function requestFixture() {
76+ function requestActiveFavorite(trigger) {
4977 if (closed) return false;
50- const request = model.beginFixtureRequest(nextRequestId());
51- if (!request) return false;
52- if (!receiver.expectResponse(request.requestId, request.favoriteId)
53- || !queue.enqueue(encodeRequest(request))) {
54- receiver.cancelExpectedResponse();
55- model.markSendFailure();
78+ sequence += 1;
79+ const request = model.beginRequest(requestPrefix + sequence, trigger);
80+ if (!request) {
81+ sequence -= 1;
82+ return false;
83+ }
84+ if (trigger === REQUEST_TRIGGER.APP_OPEN) appOpenRequested = true;
85+
86+ if (!receiver.expectResponse(
87+ request.requestId,
88+ request.favoriteId,
89+ requestPrefix,
90+ sequence
91+ )) {
92+ failRequest();
93+ return false;
94+ }
95+
96+ const failureGeneration = transportFailureGeneration;
97+ const enqueued = queue.enqueue(encodeRequest(request));
98+ if (transportFailureGeneration !== failureGeneration) return false;
99+ if (!enqueued) {
100+ failRequest();
101+ return false;
102+ }
103+ render();
104+ return true;
105+ }
106+
107+ function armSelectionRequest(favoriteId) {
108+ invalidateSelectionTimer();
109+ const generation = selectionGeneration;
110+ selectionTimer = scheduler.set(function () {
111+ if (generation !== selectionGeneration) return;
112+ selectionTimer = null;
113+ selectionGeneration += 1;
114+ if (closed || !active) return;
115+ const favorite = model.snapshot(clock.now()).activeFavorite;
116+ if (!favorite || favorite.id !== favoriteId) return;
117+ requestActiveFavorite(REQUEST_TRIGGER.FAVORITE_SELECTION);
118+ }, LIMITS.favoriteSettleMs);
119+ }
120+
121+ function applyConfiguration(snapshot) {
122+ if (storage && !saveWatchConfiguration(storage, snapshot.configuration)) {
123+ receiver.restoreState(committedProtocolState, false);
124+ model.commitProtocol(receiver.borrowState(), "CONFIG_COMMITTED");
56125 render();
57126 return false;
58127 }
128+ model.commitProtocol(snapshot, "CONFIG_COMMITTED");
129+ committedProtocolState = snapshot;
59130 render();
131+ if (!appOpenRequested) requestActiveFavorite(REQUEST_TRIGGER.APP_OPEN);
60132 return true;
61133 }
62134
135+ function deferConfiguration(snapshot) {
136+ invalidateConfigurationTimer();
137+ const generation = configurationGeneration;
138+ configurationTimer = scheduler.set(function () {
139+ if (closed || generation !== configurationGeneration) return;
140+ configurationTimer = null;
141+ configurationGeneration += 1;
142+ applyConfiguration(snapshot);
143+ }, 0);
144+ }
145+
63146 const controller = {
64147 start() {
65148 if (closed || started) return controller;
66149 started = true;
67150 const configuration = storage ? loadWatchConfiguration(storage) : null;
68151 if (configuration) {
69- committedConfiguration = configuration;
70- receiver.restoreConfiguration(configuration);
71- model.commitProtocol(receiver.snapshot(), "CONFIG_RESTORED");
72- restoredRequestPending = true;
73- render();
74- } else {
75- render();
152+ receiver.restoreConfiguration(configuration, false);
153+ committedProtocolState = receiver.borrowState();
154+ model.commitProtocol(committedProtocolState, "CONFIG_RESTORED");
76155 }
156+ render();
77157 return controller;
78158 },
79159
80160 onReadable(message) {
81161 if (closed) return RECEIVE_RESULT.REJECTED;
82162 const received = receiver.receive(message);
163+ if (received === RECEIVE_RESULT.STAGED
164+ && receiver.configStage
165+ && receiver.configStage.favorites.length === 0
166+ && storage
167+ && typeof view.releasePresentation === "function") {
168+ view.releasePresentation();
169+ }
83170 const event = eventName(received);
84- if (event === "CONFIG_COMMITTED") restoredRequestPending = false;
85- if (event !== null) {
86- let snapshot = receiver.snapshot();
87- if (event === "CONFIG_COMMITTED" && storage) {
88- if (!saveWatchConfiguration(storage, snapshot.configuration)) {
89- receiver.restoreConfiguration(committedConfiguration);
90- snapshot = receiver.snapshot();
91- const effect = model.commitProtocol(snapshot, "CONFIG_COMMITTED");
92- render();
93- if (effect.requestFixture) requestFixture();
94- return RECEIVE_RESULT.REJECTED;
95- }
96- committedConfiguration = snapshot.configuration;
97- }
98- const effect = model.commitProtocol(snapshot, event);
99- render();
100- if (effect.requestFixture) requestFixture();
171+ if (event === null) return received;
172+
173+ const snapshot = receiver.borrowState();
174+ if (event === "CONFIG_COMMITTED") {
175+ if (storage) deferConfiguration(snapshot);
176+ else applyConfiguration(snapshot);
177+ return received;
101178 }
179+
180+ model.commitProtocol(snapshot, event);
181+ committedProtocolState = snapshot;
182+ render();
102183 return received;
103184 },
104185
105186 onQueueState(state) {
106- if (closed || !state) return;
107- if (state.type === QUEUE_STATE.WRITABLE && restoredRequestPending) {
108- restoredRequestPending = false;
109- requestFixture();
187+ if (closed || !state || state.type !== QUEUE_STATE.FAILED) return;
188+ transportFailureGeneration += 1;
189+ failRequest();
190+ },
191+
192+ onButton(type) {
193+ if (closed || !active) return;
194+ if (type === "select") {
195+ invalidateSelectionTimer();
196+ requestActiveFavorite(REQUEST_TRIGGER.MANUAL_SELECT);
110197 return;
111198 }
112- if (state.type !== QUEUE_STATE.FAILED) return;
199+
200+ const delta = type === "up" ? -1 : type === "down" ? 1 : 0;
201+ if (delta === 0 || !model.moveSelection(delta)) return;
113202 receiver.cancelExpectedResponse();
114- model.markSendFailure();
115- render();
203+ model.cancelRequest();
204+ const snapshot = render();
205+ if (snapshot.activeFavorite) armSelectionRequest(snapshot.activeFavorite.id);
206+ },
207+
208+ onMinuteChange() {
209+ if (!closed && active) render();
210+ },
211+
212+ setActive(nextActive) {
213+ if (closed) return;
214+ active = nextActive === true;
215+ if (!active) invalidateSelectionTimer();
116216 },
117217
118218 redraw() {
@@ -122,7 +222,10 @@ export function createController(options) {
122222 close() {
123223 if (closed) return;
124224 closed = true;
225+ invalidateSelectionTimer();
226+ invalidateConfigurationTimer();
125227 receiver.cancelExpectedResponse();
228+ model.cancelRequest();
126229 receiver.discardStaging();
127230 queue.close();
128231 if (typeof view.close === "function") view.close();
@@ -1,4 +1,5 @@
1 import { QUEUE_STATE } from "./message-queue.js";1 import { QUEUE_STATE } from "./message-queue.js";
2+import { LIMITS, REQUEST_TRIGGER } from "./contracts.js";
2 import { ProtocolReceiver, RECEIVE_RESULT, encodeRequest } from "./protocol.js";3 import { ProtocolReceiver, RECEIVE_RESULT, encodeRequest } from "./protocol.js";
3 import { loadWatchConfiguration, saveWatchConfiguration } from "./storage.js";4 import { loadWatchConfiguration, saveWatchConfiguration } from "./storage.js";
4 5
@@ -15,6 +16,9 @@ export function createController(options) {
15 || typeof options.clock.now !== "function"16 || typeof options.clock.now !== "function"
16 || !options.queue17 || !options.queue
17 || typeof options.queue.enqueue !== "function"18 || typeof options.queue.enqueue !== "function"
19+ || !options.scheduler
20+ || typeof options.scheduler.set !== "function"
21+ || typeof options.scheduler.clear !== "function"
18 || !options.model22 || !options.model
19 || typeof options.model.snapshot !== "function"23 || typeof options.model.snapshot !== "function"
20 || !options.view24 || !options.view
@@ -24,95 +28,191 @@ export function createController(options) {
24 28
25 const clock = options.clock;29 const clock = options.clock;
26 const queue = options.queue;30 const queue = options.queue;
31+ const scheduler = options.scheduler;
27 const model = options.model;32 const model = options.model;
28 const storage = options.storage || null;33 const storage = options.storage || null;
29 const view = options.view;34 const view = options.view;
30 const receiver = new ProtocolReceiver();35 const receiver = new ProtocolReceiver();
31- let committedConfiguration = null;36+ const requestPrefix = "consult-"
37+ + Math.max(0, Math.floor(clock.now() / 1000)).toString(36)
38+ + "-";
39+ let committedProtocolState = null;
32 let sequence = 0;40 let sequence = 0;
33- let restoredRequestPending = false;41+ let selectionGeneration = 0;
42+ let selectionTimer = null;
43+ let transportFailureGeneration = 0;
44+ let configurationGeneration = 0;
45+ let configurationTimer = null;
46+ let appOpenRequested = false;
34 let started = false;47 let started = false;
35 let closed = false;48 let closed = false;
49+ let active = true;
36 50
37 function render() {51 function render() {
38- view.render(model.snapshot());52+ const snapshot = model.snapshot(clock.now(), false);
53+ view.render(snapshot);
54+ return snapshot;
39 }55 }
40 56
41- function nextRequestId() {57+ function invalidateSelectionTimer() {
42- sequence += 1;58+ selectionGeneration += 1;
43- if (sequence > 1679615) sequence = 1;59+ if (selectionTimer !== null) scheduler.clear(selectionTimer);
44- const seconds = Math.max(0, Math.floor(clock.now() / 1000));60+ selectionTimer = null;
45- return "foundation-" + seconds.toString(36) + "-" + sequence.toString(36);61+ }
62+
63+ function invalidateConfigurationTimer() {
64+ configurationGeneration += 1;
65+ if (configurationTimer !== null) scheduler.clear(configurationTimer);
66+ configurationTimer = null;
67+ }
68+
69+ function failRequest() {
70+ receiver.cancelExpectedResponse();
71+ model.cancelRequest();
72+ model.markSendFailure();
73+ render();
46 }74 }
47 75
48- function requestFixture() {76+ function requestActiveFavorite(trigger) {
49 if (closed) return false;77 if (closed) return false;
50- const request = model.beginFixtureRequest(nextRequestId());78+ sequence += 1;
51- if (!request) return false;79+ const request = model.beginRequest(requestPrefix + sequence, trigger);
52- if (!receiver.expectResponse(request.requestId, request.favoriteId)80+ if (!request) {
53- || !queue.enqueue(encodeRequest(request))) {81+ sequence -= 1;
54- receiver.cancelExpectedResponse();82+ return false;
55- model.markSendFailure();83+ }
84+ if (trigger === REQUEST_TRIGGER.APP_OPEN) appOpenRequested = true;
85+
86+ if (!receiver.expectResponse(
87+ request.requestId,
88+ request.favoriteId,
89+ requestPrefix,
90+ sequence
91+ )) {
92+ failRequest();
93+ return false;
94+ }
95+
96+ const failureGeneration = transportFailureGeneration;
97+ const enqueued = queue.enqueue(encodeRequest(request));
98+ if (transportFailureGeneration !== failureGeneration) return false;
99+ if (!enqueued) {
100+ failRequest();
101+ return false;
102+ }
103+ render();
104+ return true;
105+ }
106+
107+ function armSelectionRequest(favoriteId) {
108+ invalidateSelectionTimer();
109+ const generation = selectionGeneration;
110+ selectionTimer = scheduler.set(function () {
111+ if (generation !== selectionGeneration) return;
112+ selectionTimer = null;
113+ selectionGeneration += 1;
114+ if (closed || !active) return;
115+ const favorite = model.snapshot(clock.now()).activeFavorite;
116+ if (!favorite || favorite.id !== favoriteId) return;
117+ requestActiveFavorite(REQUEST_TRIGGER.FAVORITE_SELECTION);
118+ }, LIMITS.favoriteSettleMs);
119+ }
120+
121+ function applyConfiguration(snapshot) {
122+ if (storage && !saveWatchConfiguration(storage, snapshot.configuration)) {
123+ receiver.restoreState(committedProtocolState, false);
124+ model.commitProtocol(receiver.borrowState(), "CONFIG_COMMITTED");
56 render();125 render();
57 return false;126 return false;
58 }127 }
128+ model.commitProtocol(snapshot, "CONFIG_COMMITTED");
129+ committedProtocolState = snapshot;
59 render();130 render();
131+ if (!appOpenRequested) requestActiveFavorite(REQUEST_TRIGGER.APP_OPEN);
60 return true;132 return true;
61 }133 }
62 134
135+ function deferConfiguration(snapshot) {
136+ invalidateConfigurationTimer();
137+ const generation = configurationGeneration;
138+ configurationTimer = scheduler.set(function () {
139+ if (closed || generation !== configurationGeneration) return;
140+ configurationTimer = null;
141+ configurationGeneration += 1;
142+ applyConfiguration(snapshot);
143+ }, 0);
144+ }
145+
63 const controller = {146 const controller = {
64 start() {147 start() {
65 if (closed || started) return controller;148 if (closed || started) return controller;
66 started = true;149 started = true;
67 const configuration = storage ? loadWatchConfiguration(storage) : null;150 const configuration = storage ? loadWatchConfiguration(storage) : null;
68 if (configuration) {151 if (configuration) {
69- committedConfiguration = configuration;152+ receiver.restoreConfiguration(configuration, false);
70- receiver.restoreConfiguration(configuration);153+ committedProtocolState = receiver.borrowState();
71- model.commitProtocol(receiver.snapshot(), "CONFIG_RESTORED");154+ model.commitProtocol(committedProtocolState, "CONFIG_RESTORED");
72- restoredRequestPending = true;
73- render();
74- } else {
75- render();
76 }155 }
156+ render();
77 return controller;157 return controller;
78 },158 },
79 159
80 onReadable(message) {160 onReadable(message) {
81 if (closed) return RECEIVE_RESULT.REJECTED;161 if (closed) return RECEIVE_RESULT.REJECTED;
82 const received = receiver.receive(message);162 const received = receiver.receive(message);
163+ if (received === RECEIVE_RESULT.STAGED
164+ && receiver.configStage
165+ && receiver.configStage.favorites.length === 0
166+ && storage
167+ && typeof view.releasePresentation === "function") {
168+ view.releasePresentation();
169+ }
83 const event = eventName(received);170 const event = eventName(received);
84- if (event === "CONFIG_COMMITTED") restoredRequestPending = false;171+ if (event === null) return received;
85- if (event !== null) {172+
86- let snapshot = receiver.snapshot();173+ const snapshot = receiver.borrowState();
87- if (event === "CONFIG_COMMITTED" && storage) {174+ if (event === "CONFIG_COMMITTED") {
88- if (!saveWatchConfiguration(storage, snapshot.configuration)) {175+ if (storage) deferConfiguration(snapshot);
89- receiver.restoreConfiguration(committedConfiguration);176+ else applyConfiguration(snapshot);
90- snapshot = receiver.snapshot();177+ return received;
91- const effect = model.commitProtocol(snapshot, "CONFIG_COMMITTED");
92- render();
93- if (effect.requestFixture) requestFixture();
94- return RECEIVE_RESULT.REJECTED;
95- }
96- committedConfiguration = snapshot.configuration;
97- }
98- const effect = model.commitProtocol(snapshot, event);
99- render();
100- if (effect.requestFixture) requestFixture();
101 }178 }
179+
180+ model.commitProtocol(snapshot, event);
181+ committedProtocolState = snapshot;
182+ render();
102 return received;183 return received;
103 },184 },
104 185
105 onQueueState(state) {186 onQueueState(state) {
106- if (closed || !state) return;187+ if (closed || !state || state.type !== QUEUE_STATE.FAILED) return;
107- if (state.type === QUEUE_STATE.WRITABLE && restoredRequestPending) {188+ transportFailureGeneration += 1;
108- restoredRequestPending = false;189+ failRequest();
109- requestFixture();190+ },
191+
192+ onButton(type) {
193+ if (closed || !active) return;
194+ if (type === "select") {
195+ invalidateSelectionTimer();
196+ requestActiveFavorite(REQUEST_TRIGGER.MANUAL_SELECT);
110 return;197 return;
111 }198 }
112- if (state.type !== QUEUE_STATE.FAILED) return;199+
200+ const delta = type === "up" ? -1 : type === "down" ? 1 : 0;
201+ if (delta === 0 || !model.moveSelection(delta)) return;
113 receiver.cancelExpectedResponse();202 receiver.cancelExpectedResponse();
114- model.markSendFailure();203+ model.cancelRequest();
115- render();204+ const snapshot = render();
205+ if (snapshot.activeFavorite) armSelectionRequest(snapshot.activeFavorite.id);
206+ },
207+
208+ onMinuteChange() {
209+ if (!closed && active) render();
210+ },
211+
212+ setActive(nextActive) {
213+ if (closed) return;
214+ active = nextActive === true;
215+ if (!active) invalidateSelectionTimer();
116 },216 },
117 217
118 redraw() {218 redraw() {
@@ -122,7 +222,10 @@ export function createController(options) {
122 close() {222 close() {
123 if (closed) return;223 if (closed) return;
124 closed = true;224 closed = true;
225+ invalidateSelectionTimer();
226+ invalidateConfigurationTimer();
125 receiver.cancelExpectedResponse();227 receiver.cancelExpectedResponse();
228+ model.cancelRequest();
126 receiver.discardStaging();229 receiver.discardStaging();
127 queue.close();230 queue.close();
128 if (typeof view.close === "function") view.close();231 if (typeof view.close === "function") view.close();
modified packages/watch/src/embeddedjs/localization.js +53 -28
@@ -2,39 +2,64 @@ import { LANGUAGE as CONTRACT_LANGUAGE } from "./contracts.js";
22
33 export const LANGUAGE = CONTRACT_LANGUAGE;
44
5-export const COPY = Object.freeze({
6- en: Object.freeze({
7- appName: "Lapin Futé",
8- synchronizing: "Synchronizing fixture…",
9- waiting: "Waiting for recorded departures…",
10- unavailable: "Fixture unavailable",
11- noDepartures: "No recorded departures",
12- recordedFixture: "Recorded foundation fixture",
13- now: "Now",
14- departed: "Departed"
15- }),
16- fr: Object.freeze({
17- appName: "Lapin Futé",
18- synchronizing: "Synchronisation du scénario…",
19- waiting: "En attente des départs enregistrés…",
20- unavailable: "Scénario indisponible",
21- noDepartures: "Aucun départ enregistré",
22- recordedFixture: "Scénario de fondation enregistré",
23- now: "À quai",
24- departed: "Parti"
25- })
26-});
27-
285 export function normalizeLanguage(tag) {
296 if (typeof tag !== "string") return LANGUAGE.EN;
30- return tag.toLowerCase().split(/[-_]/)[0] === LANGUAGE.FR
31- ? LANGUAGE.FR
32- : LANGUAGE.EN;
7+ const french = (tag.charCodeAt(0) === 70 || tag.charCodeAt(0) === 102)
8+ && (tag.charCodeAt(1) === 82 || tag.charCodeAt(1) === 114)
9+ && (tag.length === 2 || tag[2] === "-" || tag[2] === "_");
10+ return french ? LANGUAGE.FR : LANGUAGE.EN;
3311 }
3412
3513 export function copy(language, id) {
36- const table = COPY[normalizeLanguage(language)];
37- return Object.prototype.hasOwnProperty.call(table, id) ? table[id] : "";
14+ const french = normalizeLanguage(language) === LANGUAGE.FR;
15+ switch (id) {
16+ case "unconfigured":
17+ return french
18+ ? "Ouvrez les options téléphone\nAjoutez une clé PRIM et un favori"
19+ : "Open phone options\nAdd a PRIM key and favorite";
20+ case "loading":
21+ return french ? "Chargement des départs…" : "Loading departures…";
22+ case "apiKeyInvalid":
23+ return french
24+ ? "Clé PRIM refusée\nOuvrez les options téléphone"
25+ : "PRIM key rejected\nOpen phone options";
26+ case "favoriteUnavailable":
27+ return french
28+ ? "Favori indisponible\nOuvrez les options téléphone"
29+ : "Favorite unavailable\nOpen phone options";
30+ case "rateLimited":
31+ return french
32+ ? "Trop de demandes\nRéessayez plus tard"
33+ : "Too many requests\nTry again later";
34+ case "departuresUnavailable":
35+ return french
36+ ? "Départs indisponibles\nSélection pour réessayer"
37+ : "Departures unavailable\nSelect to retry";
38+ case "noDepartures":
39+ return french ? "Aucun départ" : "No departures";
40+ case "delayed":
41+ return french ? "Retardé" : "Delayed";
42+ case "cancelled":
43+ return french ? "Annulé" : "Cancelled";
44+ case "then":
45+ return french ? "puis" : "then";
46+ case "freshRealtime":
47+ return french ? "Temps réel" : "Live";
48+ case "freshScheduled":
49+ return french ? "Horaires" : "Scheduled";
50+ case "freshMixed":
51+ return french ? "Temps réel + horaires" : "Live + schedule";
52+ case "freshStale":
53+ return french ? "Périmé" : "Out of date";
54+ case "updated":
55+ return french ? "Mis à jour" : "Updated";
56+ case "now":
57+ return french ? "À quai" : "Now";
58+ case "departed":
59+ return french ? "Parti" : "Departed";
60+ default:
61+ return "";
62+ }
3863 }
3964
4065 export function formatMinutes(language, minutes) {
@@ -2,39 +2,64 @@ import { LANGUAGE as CONTRACT_LANGUAGE } from "./contracts.js";
2 2
3 export const LANGUAGE = CONTRACT_LANGUAGE;3 export const LANGUAGE = CONTRACT_LANGUAGE;
4 4
5-export const COPY = Object.freeze({
6- en: Object.freeze({
7- appName: "Lapin Futé",
8- synchronizing: "Synchronizing fixture…",
9- waiting: "Waiting for recorded departures…",
10- unavailable: "Fixture unavailable",
11- noDepartures: "No recorded departures",
12- recordedFixture: "Recorded foundation fixture",
13- now: "Now",
14- departed: "Departed"
15- }),
16- fr: Object.freeze({
17- appName: "Lapin Futé",
18- synchronizing: "Synchronisation du scénario…",
19- waiting: "En attente des départs enregistrés…",
20- unavailable: "Scénario indisponible",
21- noDepartures: "Aucun départ enregistré",
22- recordedFixture: "Scénario de fondation enregistré",
23- now: "À quai",
24- departed: "Parti"
25- })
26-});
27-
28 export function normalizeLanguage(tag) {5 export function normalizeLanguage(tag) {
29 if (typeof tag !== "string") return LANGUAGE.EN;6 if (typeof tag !== "string") return LANGUAGE.EN;
30- return tag.toLowerCase().split(/[-_]/)[0] === LANGUAGE.FR7+ const french = (tag.charCodeAt(0) === 70 || tag.charCodeAt(0) === 102)
31- ? LANGUAGE.FR8+ && (tag.charCodeAt(1) === 82 || tag.charCodeAt(1) === 114)
32- : LANGUAGE.EN;9+ && (tag.length === 2 || tag[2] === "-" || tag[2] === "_");
10+ return french ? LANGUAGE.FR : LANGUAGE.EN;
33 }11 }
34 12
35 export function copy(language, id) {13 export function copy(language, id) {
36- const table = COPY[normalizeLanguage(language)];14+ const french = normalizeLanguage(language) === LANGUAGE.FR;
37- return Object.prototype.hasOwnProperty.call(table, id) ? table[id] : "";15+ switch (id) {
16+ case "unconfigured":
17+ return french
18+ ? "Ouvrez les options téléphone\nAjoutez une clé PRIM et un favori"
19+ : "Open phone options\nAdd a PRIM key and favorite";
20+ case "loading":
21+ return french ? "Chargement des départs…" : "Loading departures…";
22+ case "apiKeyInvalid":
23+ return french
24+ ? "Clé PRIM refusée\nOuvrez les options téléphone"
25+ : "PRIM key rejected\nOpen phone options";
26+ case "favoriteUnavailable":
27+ return french
28+ ? "Favori indisponible\nOuvrez les options téléphone"
29+ : "Favorite unavailable\nOpen phone options";
30+ case "rateLimited":
31+ return french
32+ ? "Trop de demandes\nRéessayez plus tard"
33+ : "Too many requests\nTry again later";
34+ case "departuresUnavailable":
35+ return french
36+ ? "Départs indisponibles\nSélection pour réessayer"
37+ : "Departures unavailable\nSelect to retry";
38+ case "noDepartures":
39+ return french ? "Aucun départ" : "No departures";
40+ case "delayed":
41+ return french ? "Retardé" : "Delayed";
42+ case "cancelled":
43+ return french ? "Annulé" : "Cancelled";
44+ case "then":
45+ return french ? "puis" : "then";
46+ case "freshRealtime":
47+ return french ? "Temps réel" : "Live";
48+ case "freshScheduled":
49+ return french ? "Horaires" : "Scheduled";
50+ case "freshMixed":
51+ return french ? "Temps réel + horaires" : "Live + schedule";
52+ case "freshStale":
53+ return french ? "Périmé" : "Out of date";
54+ case "updated":
55+ return french ? "Mis à jour" : "Updated";
56+ case "now":
57+ return french ? "À quai" : "Now";
58+ case "departed":
59+ return french ? "Parti" : "Departed";
60+ default:
61+ return "";
62+ }
38 }63 }
39 64
40 export function formatMinutes(language, minutes) {65 export function formatMinutes(language, minutes) {
modified packages/watch/src/embeddedjs/main.js +26 -5
@@ -1,5 +1,11 @@
1+import Button from "pebble/button";
12 import Message from "pebble/message";
2-import { APP_MESSAGE_KEY_MAP } from "./contracts.js";
3+import Timer from "timer";
4+import {
5+ APP_MESSAGE_INBOX_BYTES,
6+ APP_MESSAGE_KEY_MAP,
7+ APP_MESSAGE_OUTBOX_BYTES
8+} from "./contracts.js";
39 import { createController } from "./controller.js";
410 import MessageQueue from "./message-queue.js";
511 import WatchModel from "./model.js";
@@ -9,7 +15,8 @@ function screenInfo() {
915 return {
1016 width: screen.width,
1117 height: screen.height,
12- round: screen.round === true
18+ round: screen.round === true,
19+ hour12: watch.hour12 === true
1320 };
1421 }
1522
@@ -20,6 +27,8 @@ const pendingMessages = [];
2027 const view = createWatchView(screenInfo());
2128 const message = new Message({
2229 keys: APP_MESSAGE_KEY_MAP,
30+ input: APP_MESSAGE_INBOX_BYTES,
31+ output: APP_MESSAGE_OUTBOX_BYTES,
2332 onReadable() {
2433 const incoming = this.read();
2534 if (controller) controller.onReadable(incoming);
@@ -42,7 +51,8 @@ queue = new MessageQueue(message, {
4251 });
4352
4453 controller = createController({
45- clock: { now: Date.now },
54+ clock: Date,
55+ scheduler: Timer,
4656 queue,
4757 model: new WatchModel(),
4858 storage: localStorage,
@@ -51,10 +61,21 @@ controller = createController({
5161 controller.start();
5262 if (pendingChannel === "writable") queue.writable();
5363 else if (pendingChannel === "suspended") queue.suspend();
54-pendingMessages.forEach(function (incoming) {
55- controller.onReadable(incoming);
64+for (let index = 0; index < pendingMessages.length; index += 1) {
65+ controller.onReadable(pendingMessages[index]);
66+}
67+const button = new Button({
68+ types: ["select", "up", "down"],
69+ single: true,
70+ onPush(active, type) {
71+ if (active) controller.onButton(type);
72+ }
5673 });
5774
75+watch.addEventListener("minutechange", controller.onMinuteChange);
76+
77+watch.addEventListener("willFocus", controller.setActive);
78+
5879 watch.addEventListener("resize", function () {
5980 view.resize(screenInfo());
6081 controller.redraw();
@@ -1,5 +1,11 @@
1+import Button from "pebble/button";
1 import Message from "pebble/message";2 import Message from "pebble/message";
2-import { APP_MESSAGE_KEY_MAP } from "./contracts.js";3+import Timer from "timer";
4+import {
5+ APP_MESSAGE_INBOX_BYTES,
6+ APP_MESSAGE_KEY_MAP,
7+ APP_MESSAGE_OUTBOX_BYTES
8+} from "./contracts.js";
3 import { createController } from "./controller.js";9 import { createController } from "./controller.js";
4 import MessageQueue from "./message-queue.js";10 import MessageQueue from "./message-queue.js";
5 import WatchModel from "./model.js";11 import WatchModel from "./model.js";
@@ -9,7 +15,8 @@ function screenInfo() {
9 return {15 return {
10 width: screen.width,16 width: screen.width,
11 height: screen.height,17 height: screen.height,
12- round: screen.round === true18+ round: screen.round === true,
19+ hour12: watch.hour12 === true
13 };20 };
14 }21 }
15 22
@@ -20,6 +27,8 @@ const pendingMessages = [];
20 const view = createWatchView(screenInfo());27 const view = createWatchView(screenInfo());
21 const message = new Message({28 const message = new Message({
22 keys: APP_MESSAGE_KEY_MAP,29 keys: APP_MESSAGE_KEY_MAP,
30+ input: APP_MESSAGE_INBOX_BYTES,
31+ output: APP_MESSAGE_OUTBOX_BYTES,
23 onReadable() {32 onReadable() {
24 const incoming = this.read();33 const incoming = this.read();
25 if (controller) controller.onReadable(incoming);34 if (controller) controller.onReadable(incoming);
@@ -42,7 +51,8 @@ queue = new MessageQueue(message, {
42 });51 });
43 52
44 controller = createController({53 controller = createController({
45- clock: { now: Date.now },54+ clock: Date,
55+ scheduler: Timer,
46 queue,56 queue,
47 model: new WatchModel(),57 model: new WatchModel(),
48 storage: localStorage,58 storage: localStorage,
@@ -51,10 +61,21 @@ controller = createController({
51 controller.start();61 controller.start();
52 if (pendingChannel === "writable") queue.writable();62 if (pendingChannel === "writable") queue.writable();
53 else if (pendingChannel === "suspended") queue.suspend();63 else if (pendingChannel === "suspended") queue.suspend();
54-pendingMessages.forEach(function (incoming) {64+for (let index = 0; index < pendingMessages.length; index += 1) {
55- controller.onReadable(incoming);65+ controller.onReadable(pendingMessages[index]);
66+}
67+const button = new Button({
68+ types: ["select", "up", "down"],
69+ single: true,
70+ onPush(active, type) {
71+ if (active) controller.onButton(type);
72+ }
56 });73 });
57 74
75+watch.addEventListener("minutechange", controller.onMinuteChange);
76+
77+watch.addEventListener("willFocus", controller.setActive);
78+
58 watch.addEventListener("resize", function () {79 watch.addEventListener("resize", function () {
59 view.resize(screenInfo());80 view.resize(screenInfo());
60 controller.redraw();81 controller.redraw();
modified packages/watch/src/embeddedjs/manifest.json +1 -0
@@ -11,6 +11,7 @@
1111 "./protocol",
1212 "./message-queue",
1313 "./localization",
14+ "./presentation",
1415 "./storage",
1516 "./ui",
1617 "./contracts"
@@ -11,6 +11,7 @@
11 "./protocol",11 "./protocol",
12 "./message-queue",12 "./message-queue",
13 "./localization",13 "./localization",
14+ "./presentation",
14 "./storage",15 "./storage",
15 "./ui",16 "./ui",
16 "./contracts"17 "./contracts"
modified packages/watch/src/embeddedjs/model.js +192 -41
@@ -1,4 +1,20 @@
1-import { KEY_STATUS, REQUEST_TRIGGER } from "./contracts.js";
1+import {
2+ DEPARTURE_STATUS,
3+ ERROR_CODE,
4+ FRESHNESS,
5+ KEY_STATUS,
6+ REQUEST_TRIGGER
7+} from "./contracts.js";
8+
9+const CACHE_FRESH_MS = 60 * 1000;
10+
11+export const WATCH_STATE = Object.freeze({
12+ UNCONFIGURED: "UNCONFIGURED",
13+ LOADING: "LOADING",
14+ READY: "READY",
15+ STALE: "STALE",
16+ UNAVAILABLE: "UNAVAILABLE"
17+});
218
319 function copyFavorite(favorite) {
420 if (!favorite) return null;
@@ -14,80 +30,215 @@ function copyFavorite(favorite) {
1430 return copy;
1531 }
1632
17-function matchingFavorite(configuration) {
18- if (!configuration || configuration.favorites.length === 0) return null;
19- return configuration.favorites[0];
33+function copyDeparture(departure, nowMs) {
34+ const copy = {
35+ expectedAt: departure.expectedAt,
36+ minutes: departure.minutes,
37+ status: departure.status,
38+ countdownMinutes: departure.status === DEPARTURE_STATUS[2]
39+ ? null
40+ : Math.ceil((departure.expectedAt * 1000 - nowMs) / 60000)
41+ };
42+ if (departure.aimedAt !== undefined) copy.aimedAt = departure.aimedAt;
43+ if (departure.nextIntervalMinutes !== undefined) {
44+ copy.nextIntervalMinutes = departure.nextIntervalMinutes;
45+ }
46+ return copy;
47+}
48+
49+function copyResult(result, nowMs) {
50+ if (!result) return null;
51+ const departures = [];
52+ for (let index = 0; index < result.departures.length; index += 1) {
53+ departures.push(copyDeparture(result.departures[index], nowMs));
54+ }
55+ const copy = {
56+ requestId: result.requestId,
57+ favoriteId: result.favoriteId,
58+ fetchedAt: result.fetchedAt,
59+ freshness: result.freshness,
60+ departures
61+ };
62+ if (result.sourceUpdatedAt !== undefined) copy.sourceUpdatedAt = result.sourceUpdatedAt;
63+ return copy;
64+}
65+
66+function copyError(error) {
67+ if (!error) return null;
68+ const copy = {
69+ requestId: error.requestId,
70+ code: error.code,
71+ occurredAt: error.occurredAt
72+ };
73+ if (error.favoriteId !== undefined) copy.favoriteId = error.favoriteId;
74+ if (error.retryAfterSeconds !== undefined) {
75+ copy.retryAfterSeconds = error.retryAfterSeconds;
76+ }
77+ return copy;
78+}
79+
80+function findFavorite(configuration, favoriteId) {
81+ if (!configuration) return null;
82+ for (let index = 0; index < configuration.favorites.length; index += 1) {
83+ if (configuration.favorites[index].id === favoriteId) {
84+ return configuration.favorites[index];
85+ }
86+ }
87+ return null;
88+}
89+
90+function activeResult(result, favorite) {
91+ return favorite && result && result.favoriteId === favorite.id ? result : null;
2092 }
21-function fixtureFavorite(configuration) {
22- return configuration && configuration.keyStatus === KEY_STATUS.CONFIGURED
23- ? matchingFavorite(configuration)
93+
94+function activeError(error, favorite) {
95+ return favorite
96+ && error
97+ && (error.favoriteId === undefined || error.favoriteId === favorite.id)
98+ ? error
2499 : null;
25100 }
26101
102+function validTrigger(trigger) {
103+ return trigger === REQUEST_TRIGGER.APP_OPEN
104+ || trigger === REQUEST_TRIGGER.FAVORITE_SELECTION
105+ || trigger === REQUEST_TRIGGER.MANUAL_SELECT;
106+}
107+
108+function stateFor(configuration, favorite, result, error, sendFailed, nowMs) {
109+ if (!configuration
110+ || configuration.keyStatus === KEY_STATUS.MISSING
111+ || !favorite
112+ || (error && error.code === ERROR_CODE[0])) {
113+ return WATCH_STATE.UNCONFIGURED;
114+ }
115+ if (configuration.keyStatus === KEY_STATUS.INVALID
116+ || (error && error.code === ERROR_CODE[1])) {
117+ return WATCH_STATE.UNAVAILABLE;
118+ }
119+ if (result) {
120+ const age = nowMs - result.fetchedAt * 1000;
121+ if (error
122+ || sendFailed
123+ || result.freshness === FRESHNESS[3]
124+ || age < 0
125+ || age >= CACHE_FRESH_MS) {
126+ return WATCH_STATE.STALE;
127+ }
128+ return WATCH_STATE.READY;
129+ }
130+ if (error || sendFailed) return WATCH_STATE.UNAVAILABLE;
131+ return WATCH_STATE.LOADING;
132+}
27133
28134 export class WatchModel {
29135 constructor() {
30136 this.configuration = null;
137+ this.activeFavoriteId = null;
31138 this.result = null;
32139 this.error = null;
33140 this.expected = null;
34- this.appOpenPending = true;
35- this.sendFailed = false;
141+ this.sendFailureFavoriteId = null;
36142 }
37143
38144 commitProtocol(protocolSnapshot, event) {
145+ const previousActiveId = this.activeFavoriteId;
39146 this.configuration = protocolSnapshot.configuration;
40147 this.result = protocolSnapshot.result;
41148 this.error = protocolSnapshot.error;
42149
43- if (event === "CONFIG_COMMITTED") {
44- this.expected = null;
45- this.sendFailed = false;
46- if (this.appOpenPending && fixtureFavorite(this.configuration)) {
47- this.appOpenPending = false;
48- return { requestFixture: true };
49- }
150+ const preserved = findFavorite(this.configuration, previousActiveId);
151+ const first = this.configuration && this.configuration.favorites.length > 0
152+ ? this.configuration.favorites[0]
153+ : null;
154+ this.activeFavoriteId = preserved ? preserved.id : first ? first.id : null;
155+
156+ if (event === "CONFIG_COMMITTED" || event === "CONFIG_RESTORED") {
157+ this.cancelRequest();
158+ this.sendFailureFavoriteId = null;
50159 } else if (event === "RESULT_COMMITTED" || event === "ERROR_COMMITTED") {
51- this.expected = null;
52- this.sendFailed = false;
160+ this.cancelRequest();
161+ this.sendFailureFavoriteId = null;
162+ }
163+ }
164+
165+ moveSelection(delta) {
166+ if (!this.configuration
167+ || this.configuration.favorites.length === 0
168+ || typeof delta !== "number"
169+ || !isFinite(delta)
170+ || Math.floor(delta) !== delta
171+ || delta === 0) {
172+ return false;
173+ }
174+
175+ const favorites = this.configuration.favorites;
176+ let currentIndex = 0;
177+ for (let index = 0; index < favorites.length; index += 1) {
178+ if (favorites[index].id === this.activeFavoriteId) {
179+ currentIndex = index;
180+ break;
181+ }
53182 }
54- return { requestFixture: false };
183+ const nextIndex = Math.max(0, Math.min(favorites.length - 1, currentIndex + delta));
184+ const nextId = favorites[nextIndex].id;
185+ if (nextId === this.activeFavoriteId) return false;
186+ this.activeFavoriteId = nextId;
187+ return true;
55188 }
56189
57- beginFixtureRequest(requestId) {
58- const favorite = fixtureFavorite(this.configuration);
59- if (!favorite || this.expected) return null;
190+ beginRequest(requestId, trigger) {
191+ const favorite = findFavorite(this.configuration, this.activeFavoriteId);
192+ if (!favorite
193+ || this.configuration.keyStatus !== KEY_STATUS.CONFIGURED
194+ || !validTrigger(trigger)
195+ || (this.expected && !activeResult(this.result, favorite))) {
196+ return null;
197+ }
60198 this.expected = {
61199 requestId,
62200 favoriteId: favorite.id,
63- trigger: REQUEST_TRIGGER.APP_OPEN
201+ trigger
64202 };
65- this.sendFailed = false;
66- return { ...this.expected };
203+ if (this.sendFailureFavoriteId === favorite.id) {
204+ this.sendFailureFavoriteId = null;
205+ }
206+ return this.expected;
207+ }
208+
209+ cancelRequest() {
210+ this.expected = null;
67211 }
68212
69213 markSendFailure() {
214+ const favorite = findFavorite(this.configuration, this.activeFavoriteId);
70215 this.expected = null;
71- this.sendFailed = true;
216+ this.sendFailureFavoriteId = favorite ? favorite.id : null;
72217 }
73218
74- snapshot() {
75- const favorite = matchingFavorite(this.configuration);
76- const result = favorite && this.result && this.result.favoriteId === favorite.id
77- ? this.result
78- : null;
79- const error = favorite
80- && this.error
81- && (this.error.favoriteId === undefined || this.error.favoriteId === favorite.id)
82- ? this.error
83- : null;
219+ snapshot(nowMs, copy = true) {
220+ const favorite = findFavorite(this.configuration, this.activeFavoriteId);
221+ const result = activeResult(this.result, favorite);
222+ const error = activeError(this.error, favorite);
223+ const sendFailed = Boolean(
224+ favorite && this.sendFailureFavoriteId === favorite.id
225+ );
84226 return {
227+ state: stateFor(
228+ this.configuration,
229+ favorite,
230+ result,
231+ error,
232+ sendFailed,
233+ nowMs
234+ ),
85235 language: this.configuration ? this.configuration.language : "en",
86- favorite: copyFavorite(favorite),
87- result,
88- error,
89- sendFailed: this.sendFailed,
90- expectedRequest: this.expected ? { ...this.expected } : null
236+ nowMs,
237+ activeFavorite: copy ? copyFavorite(favorite) : favorite,
238+ result: copy ? copyResult(result, nowMs) : result,
239+ error: copy ? copyError(error) : error,
240+ sendFailed,
241+ expectedRequest: copy && this.expected ? { ...this.expected } : this.expected
91242 };
92243 }
93244 }
@@ -1,4 +1,20 @@
1-import { KEY_STATUS, REQUEST_TRIGGER } from "./contracts.js";1+import {
2+ DEPARTURE_STATUS,
3+ ERROR_CODE,
4+ FRESHNESS,
5+ KEY_STATUS,
6+ REQUEST_TRIGGER
7+} from "./contracts.js";
8+
9+const CACHE_FRESH_MS = 60 * 1000;
10+
11+export const WATCH_STATE = Object.freeze({
12+ UNCONFIGURED: "UNCONFIGURED",
13+ LOADING: "LOADING",
14+ READY: "READY",
15+ STALE: "STALE",
16+ UNAVAILABLE: "UNAVAILABLE"
17+});
2 18
3 function copyFavorite(favorite) {19 function copyFavorite(favorite) {
4 if (!favorite) return null;20 if (!favorite) return null;
@@ -14,80 +30,215 @@ function copyFavorite(favorite) {
14 return copy;30 return copy;
15 }31 }
16 32
17-function matchingFavorite(configuration) {33+function copyDeparture(departure, nowMs) {
18- if (!configuration || configuration.favorites.length === 0) return null;34+ const copy = {
19- return configuration.favorites[0];35+ expectedAt: departure.expectedAt,
36+ minutes: departure.minutes,
37+ status: departure.status,
38+ countdownMinutes: departure.status === DEPARTURE_STATUS[2]
39+ ? null
40+ : Math.ceil((departure.expectedAt * 1000 - nowMs) / 60000)
41+ };
42+ if (departure.aimedAt !== undefined) copy.aimedAt = departure.aimedAt;
43+ if (departure.nextIntervalMinutes !== undefined) {
44+ copy.nextIntervalMinutes = departure.nextIntervalMinutes;
45+ }
46+ return copy;
47+}
48+
49+function copyResult(result, nowMs) {
50+ if (!result) return null;
51+ const departures = [];
52+ for (let index = 0; index < result.departures.length; index += 1) {
53+ departures.push(copyDeparture(result.departures[index], nowMs));
54+ }
55+ const copy = {
56+ requestId: result.requestId,
57+ favoriteId: result.favoriteId,
58+ fetchedAt: result.fetchedAt,
59+ freshness: result.freshness,
60+ departures
61+ };
62+ if (result.sourceUpdatedAt !== undefined) copy.sourceUpdatedAt = result.sourceUpdatedAt;
63+ return copy;
64+}
65+
66+function copyError(error) {
67+ if (!error) return null;
68+ const copy = {
69+ requestId: error.requestId,
70+ code: error.code,
71+ occurredAt: error.occurredAt
72+ };
73+ if (error.favoriteId !== undefined) copy.favoriteId = error.favoriteId;
74+ if (error.retryAfterSeconds !== undefined) {
75+ copy.retryAfterSeconds = error.retryAfterSeconds;
76+ }
77+ return copy;
78+}
79+
80+function findFavorite(configuration, favoriteId) {
81+ if (!configuration) return null;
82+ for (let index = 0; index < configuration.favorites.length; index += 1) {
83+ if (configuration.favorites[index].id === favoriteId) {
84+ return configuration.favorites[index];
85+ }
86+ }
87+ return null;
88+}
89+
90+function activeResult(result, favorite) {
91+ return favorite && result && result.favoriteId === favorite.id ? result : null;
20 }92 }
21-function fixtureFavorite(configuration) {93+
22- return configuration && configuration.keyStatus === KEY_STATUS.CONFIGURED94+function activeError(error, favorite) {
23- ? matchingFavorite(configuration)95+ return favorite
96+ && error
97+ && (error.favoriteId === undefined || error.favoriteId === favorite.id)
98+ ? error
24 : null;99 : null;
25 }100 }
26 101
102+function validTrigger(trigger) {
103+ return trigger === REQUEST_TRIGGER.APP_OPEN
104+ || trigger === REQUEST_TRIGGER.FAVORITE_SELECTION
105+ || trigger === REQUEST_TRIGGER.MANUAL_SELECT;
106+}
107+
108+function stateFor(configuration, favorite, result, error, sendFailed, nowMs) {
109+ if (!configuration
110+ || configuration.keyStatus === KEY_STATUS.MISSING
111+ || !favorite
112+ || (error && error.code === ERROR_CODE[0])) {
113+ return WATCH_STATE.UNCONFIGURED;
114+ }
115+ if (configuration.keyStatus === KEY_STATUS.INVALID
116+ || (error && error.code === ERROR_CODE[1])) {
117+ return WATCH_STATE.UNAVAILABLE;
118+ }
119+ if (result) {
120+ const age = nowMs - result.fetchedAt * 1000;
121+ if (error
122+ || sendFailed
123+ || result.freshness === FRESHNESS[3]
124+ || age < 0
125+ || age >= CACHE_FRESH_MS) {
126+ return WATCH_STATE.STALE;
127+ }
128+ return WATCH_STATE.READY;
129+ }
130+ if (error || sendFailed) return WATCH_STATE.UNAVAILABLE;
131+ return WATCH_STATE.LOADING;
132+}
27 133
28 export class WatchModel {134 export class WatchModel {
29 constructor() {135 constructor() {
30 this.configuration = null;136 this.configuration = null;
137+ this.activeFavoriteId = null;
31 this.result = null;138 this.result = null;
32 this.error = null;139 this.error = null;
33 this.expected = null;140 this.expected = null;
34- this.appOpenPending = true;141+ this.sendFailureFavoriteId = null;
35- this.sendFailed = false;
36 }142 }
37 143
38 commitProtocol(protocolSnapshot, event) {144 commitProtocol(protocolSnapshot, event) {
145+ const previousActiveId = this.activeFavoriteId;
39 this.configuration = protocolSnapshot.configuration;146 this.configuration = protocolSnapshot.configuration;
40 this.result = protocolSnapshot.result;147 this.result = protocolSnapshot.result;
41 this.error = protocolSnapshot.error;148 this.error = protocolSnapshot.error;
42 149
43- if (event === "CONFIG_COMMITTED") {150+ const preserved = findFavorite(this.configuration, previousActiveId);
44- this.expected = null;151+ const first = this.configuration && this.configuration.favorites.length > 0
45- this.sendFailed = false;152+ ? this.configuration.favorites[0]
46- if (this.appOpenPending && fixtureFavorite(this.configuration)) {153+ : null;
47- this.appOpenPending = false;154+ this.activeFavoriteId = preserved ? preserved.id : first ? first.id : null;
48- return { requestFixture: true };155+
49- }156+ if (event === "CONFIG_COMMITTED" || event === "CONFIG_RESTORED") {
157+ this.cancelRequest();
158+ this.sendFailureFavoriteId = null;
50 } else if (event === "RESULT_COMMITTED" || event === "ERROR_COMMITTED") {159 } else if (event === "RESULT_COMMITTED" || event === "ERROR_COMMITTED") {
51- this.expected = null;160+ this.cancelRequest();
52- this.sendFailed = false;161+ this.sendFailureFavoriteId = null;
162+ }
163+ }
164+
165+ moveSelection(delta) {
166+ if (!this.configuration
167+ || this.configuration.favorites.length === 0
168+ || typeof delta !== "number"
169+ || !isFinite(delta)
170+ || Math.floor(delta) !== delta
171+ || delta === 0) {
172+ return false;
173+ }
174+
175+ const favorites = this.configuration.favorites;
176+ let currentIndex = 0;
177+ for (let index = 0; index < favorites.length; index += 1) {
178+ if (favorites[index].id === this.activeFavoriteId) {
179+ currentIndex = index;
180+ break;
181+ }
53 }182 }
54- return { requestFixture: false };183+ const nextIndex = Math.max(0, Math.min(favorites.length - 1, currentIndex + delta));
184+ const nextId = favorites[nextIndex].id;
185+ if (nextId === this.activeFavoriteId) return false;
186+ this.activeFavoriteId = nextId;
187+ return true;
55 }188 }
56 189
57- beginFixtureRequest(requestId) {190+ beginRequest(requestId, trigger) {
58- const favorite = fixtureFavorite(this.configuration);191+ const favorite = findFavorite(this.configuration, this.activeFavoriteId);
59- if (!favorite || this.expected) return null;192+ if (!favorite
193+ || this.configuration.keyStatus !== KEY_STATUS.CONFIGURED
194+ || !validTrigger(trigger)
195+ || (this.expected && !activeResult(this.result, favorite))) {
196+ return null;
197+ }
60 this.expected = {198 this.expected = {
61 requestId,199 requestId,
62 favoriteId: favorite.id,200 favoriteId: favorite.id,
63- trigger: REQUEST_TRIGGER.APP_OPEN201+ trigger
64 };202 };
65- this.sendFailed = false;203+ if (this.sendFailureFavoriteId === favorite.id) {
66- return { ...this.expected };204+ this.sendFailureFavoriteId = null;
205+ }
206+ return this.expected;
207+ }
208+
209+ cancelRequest() {
210+ this.expected = null;
67 }211 }
68 212
69 markSendFailure() {213 markSendFailure() {
214+ const favorite = findFavorite(this.configuration, this.activeFavoriteId);
70 this.expected = null;215 this.expected = null;
71- this.sendFailed = true;216+ this.sendFailureFavoriteId = favorite ? favorite.id : null;
72 }217 }
73 218
74- snapshot() {219+ snapshot(nowMs, copy = true) {
75- const favorite = matchingFavorite(this.configuration);220+ const favorite = findFavorite(this.configuration, this.activeFavoriteId);
76- const result = favorite && this.result && this.result.favoriteId === favorite.id221+ const result = activeResult(this.result, favorite);
77- ? this.result222+ const error = activeError(this.error, favorite);
78- : null;223+ const sendFailed = Boolean(
79- const error = favorite224+ favorite && this.sendFailureFavoriteId === favorite.id
80- && this.error225+ );
81- && (this.error.favoriteId === undefined || this.error.favoriteId === favorite.id)
82- ? this.error
83- : null;
84 return {226 return {
227+ state: stateFor(
228+ this.configuration,
229+ favorite,
230+ result,
231+ error,
232+ sendFailed,
233+ nowMs
234+ ),
85 language: this.configuration ? this.configuration.language : "en",235 language: this.configuration ? this.configuration.language : "en",
86- favorite: copyFavorite(favorite),236+ nowMs,
87- result,237+ activeFavorite: copy ? copyFavorite(favorite) : favorite,
88- error,238+ result: copy ? copyResult(result, nowMs) : result,
89- sendFailed: this.sendFailed,239+ error: copy ? copyError(error) : error,
90- expectedRequest: this.expected ? { ...this.expected } : null240+ sendFailed,
241+ expectedRequest: copy && this.expected ? { ...this.expected } : this.expected
91 };242 };
92 }243 }
93 }244 }
added packages/watch/src/embeddedjs/presentation.js +108 -0
new file mode 100644
@@ -0,0 +1,108 @@
1+import {
2+ DEPARTURE_STATUS,
3+ ERROR_CODE,
4+ FRESHNESS
5+} from "./contracts.js";
6+import { copy, formatMinutes } from "./localization.js";
7+import { WATCH_STATE } from "./model.js";
8+
9+function formatClock(seconds, hour12) {
10+ const date = new Date(seconds * 1000);
11+ let hours = date.getHours();
12+ if (hour12) hours = hours % 12 || 12;
13+ const hourText = hours < 10 ? "0" + hours : String(hours);
14+ const minutes = date.getMinutes();
15+ const minuteText = minutes < 10 ? "0" + minutes : String(minutes);
16+ return hourText + ":" + minuteText;
17+}
18+
19+function errorMessage(language, error, sendFailed) {
20+ if (!error) {
21+ return copy(language, sendFailed ? "departuresUnavailable" : "apiKeyInvalid");
22+ }
23+ switch (error.code) {
24+ case ERROR_CODE[0]:
25+ return copy(language, "unconfigured");
26+ case ERROR_CODE[1]:
27+ return copy(language, "apiKeyInvalid");
28+ case ERROR_CODE[2]:
29+ return copy(language, "favoriteUnavailable");
30+ case ERROR_CODE[4]:
31+ return copy(language, "rateLimited");
32+ case ERROR_CODE[3]:
33+ case ERROR_CODE[5]:
34+ default:
35+ return copy(language, "departuresUnavailable");
36+ }
37+}
38+
39+function freshnessCopyId(state, freshness) {
40+ if (state === WATCH_STATE.STALE || freshness === FRESHNESS[3]) return "freshStale";
41+ if (freshness === FRESHNESS[0]) return "freshRealtime";
42+ if (freshness === FRESHNESS[1]) return "freshScheduled";
43+ if (freshness === FRESHNESS[2]) return "freshMixed";
44+ return "freshStale";
45+}
46+
47+function departureRow(language, departure, nowMs) {
48+ if (departure.status === DEPARTURE_STATUS[2]) {
49+ return copy(language, "cancelled");
50+ }
51+ const countdown = departure.countdownMinutes === undefined
52+ ? Math.ceil((departure.expectedAt * 1000 - nowMs) / 60000)
53+ : departure.countdownMinutes;
54+ let row = formatMinutes(language, countdown);
55+ if (departure.status === DEPARTURE_STATUS[1]) {
56+ row += " · " + copy(language, "delayed");
57+ }
58+ if (departure.nextIntervalMinutes !== undefined) {
59+ row += " · " + copy(language, "then")
60+ + " " + departure.nextIntervalMinutes + " min";
61+ }
62+ return row;
63+}
64+
65+export function createPresentation(snapshot, screenInfo) {
66+ const language = snapshot.language;
67+ const favorite = snapshot.activeFavorite;
68+ const rowLimit = screenInfo && screenInfo.round === true ? 2 : 3;
69+ const favoriteLabel = favorite
70+ ? favorite.displayName || favorite.stopLabel
71+ : "";
72+ const header = favorite
73+ ? favorite.lineLabel + "\n" + favorite.destinationLabel
74+ : "";
75+ const rows = [];
76+ let stateMessage = "";
77+ let footer = "";
78+ const displaysResult = snapshot.result
79+ && (snapshot.state === WATCH_STATE.READY || snapshot.state === WATCH_STATE.STALE);
80+
81+ if (displaysResult) {
82+ const count = Math.min(rowLimit, snapshot.result.departures.length);
83+ for (let index = 0; index < count; index += 1) {
84+ rows.push(departureRow(language, snapshot.result.departures[index], snapshot.nowMs));
85+ }
86+ if (rows.length === 0) stateMessage = copy(language, "noDepartures");
87+ footer = copy(
88+ language,
89+ freshnessCopyId(snapshot.state, snapshot.result.freshness)
90+ ) + " · " + copy(language, "updated") + " "
91+ + formatClock(snapshot.result.fetchedAt, screenInfo && screenInfo.hour12 === true);
92+ } else if (snapshot.state === WATCH_STATE.UNCONFIGURED) {
93+ stateMessage = copy(language, "unconfigured");
94+ } else if (snapshot.state === WATCH_STATE.LOADING) {
95+ stateMessage = copy(language, "loading");
96+ } else if (snapshot.state === WATCH_STATE.UNAVAILABLE) {
97+ stateMessage = errorMessage(language, snapshot.error, snapshot.sendFailed);
98+ }
99+
100+ return {
101+ favoriteLabel,
102+ header,
103+ rows,
104+ stateMessage,
105+ footer,
106+ rowLimit
107+ };
108+}
new file mode 100644
@@ -0,0 +1,108 @@
1+import {
2+ DEPARTURE_STATUS,
3+ ERROR_CODE,
4+ FRESHNESS
5+} from "./contracts.js";
6+import { copy, formatMinutes } from "./localization.js";
7+import { WATCH_STATE } from "./model.js";
8+
9+function formatClock(seconds, hour12) {
10+ const date = new Date(seconds * 1000);
11+ let hours = date.getHours();
12+ if (hour12) hours = hours % 12 || 12;
13+ const hourText = hours < 10 ? "0" + hours : String(hours);
14+ const minutes = date.getMinutes();
15+ const minuteText = minutes < 10 ? "0" + minutes : String(minutes);
16+ return hourText + ":" + minuteText;
17+}
18+
19+function errorMessage(language, error, sendFailed) {
20+ if (!error) {
21+ return copy(language, sendFailed ? "departuresUnavailable" : "apiKeyInvalid");
22+ }
23+ switch (error.code) {
24+ case ERROR_CODE[0]:
25+ return copy(language, "unconfigured");
26+ case ERROR_CODE[1]:
27+ return copy(language, "apiKeyInvalid");
28+ case ERROR_CODE[2]:
29+ return copy(language, "favoriteUnavailable");
30+ case ERROR_CODE[4]:
31+ return copy(language, "rateLimited");
32+ case ERROR_CODE[3]:
33+ case ERROR_CODE[5]:
34+ default:
35+ return copy(language, "departuresUnavailable");
36+ }
37+}
38+
39+function freshnessCopyId(state, freshness) {
40+ if (state === WATCH_STATE.STALE || freshness === FRESHNESS[3]) return "freshStale";
41+ if (freshness === FRESHNESS[0]) return "freshRealtime";
42+ if (freshness === FRESHNESS[1]) return "freshScheduled";
43+ if (freshness === FRESHNESS[2]) return "freshMixed";
44+ return "freshStale";
45+}
46+
47+function departureRow(language, departure, nowMs) {
48+ if (departure.status === DEPARTURE_STATUS[2]) {
49+ return copy(language, "cancelled");
50+ }
51+ const countdown = departure.countdownMinutes === undefined
52+ ? Math.ceil((departure.expectedAt * 1000 - nowMs) / 60000)
53+ : departure.countdownMinutes;
54+ let row = formatMinutes(language, countdown);
55+ if (departure.status === DEPARTURE_STATUS[1]) {
56+ row += " · " + copy(language, "delayed");
57+ }
58+ if (departure.nextIntervalMinutes !== undefined) {
59+ row += " · " + copy(language, "then")
60+ + " " + departure.nextIntervalMinutes + " min";
61+ }
62+ return row;
63+}
64+
65+export function createPresentation(snapshot, screenInfo) {
66+ const language = snapshot.language;
67+ const favorite = snapshot.activeFavorite;
68+ const rowLimit = screenInfo && screenInfo.round === true ? 2 : 3;
69+ const favoriteLabel = favorite
70+ ? favorite.displayName || favorite.stopLabel
71+ : "";
72+ const header = favorite
73+ ? favorite.lineLabel + "\n" + favorite.destinationLabel
74+ : "";
75+ const rows = [];
76+ let stateMessage = "";
77+ let footer = "";
78+ const displaysResult = snapshot.result
79+ && (snapshot.state === WATCH_STATE.READY || snapshot.state === WATCH_STATE.STALE);
80+
81+ if (displaysResult) {
82+ const count = Math.min(rowLimit, snapshot.result.departures.length);
83+ for (let index = 0; index < count; index += 1) {
84+ rows.push(departureRow(language, snapshot.result.departures[index], snapshot.nowMs));
85+ }
86+ if (rows.length === 0) stateMessage = copy(language, "noDepartures");
87+ footer = copy(
88+ language,
89+ freshnessCopyId(snapshot.state, snapshot.result.freshness)
90+ ) + " · " + copy(language, "updated") + " "
91+ + formatClock(snapshot.result.fetchedAt, screenInfo && screenInfo.hour12 === true);
92+ } else if (snapshot.state === WATCH_STATE.UNCONFIGURED) {
93+ stateMessage = copy(language, "unconfigured");
94+ } else if (snapshot.state === WATCH_STATE.LOADING) {
95+ stateMessage = copy(language, "loading");
96+ } else if (snapshot.state === WATCH_STATE.UNAVAILABLE) {
97+ stateMessage = errorMessage(language, snapshot.error, snapshot.sendFailed);
98+ }
99+
100+ return {
101+ favoriteLabel,
102+ header,
103+ rows,
104+ stateMessage,
105+ footer,
106+ rowLimit
107+ };
108+}
modified packages/watch/src/embeddedjs/protocol.js +273 -132
@@ -22,11 +22,62 @@ export const RECEIVE_RESULT = Object.freeze({
2222 ERROR_COMMITTED: 4
2323 });
2424
25-const COMMON_KEYS = Object.freeze(["SCHEMA_VERSION", "MESSAGE_TYPE"]);
26-
27-function onlyKeys(message, allowed) {
25+function onlyKeys(message, type) {
2826 for (const key of message.keys()) {
29- if (typeof key !== "string" || allowed.indexOf(key) === -1) return false;
27+ if (typeof key !== "string") return false;
28+ let allowed = key === "SCHEMA_VERSION"
29+ || key === "MESSAGE_TYPE"
30+ || key === "REQUEST_ID";
31+ if (!allowed) {
32+ switch (type) {
33+ case MESSAGE_TYPE.REQUEST:
34+ allowed = key === "FAVORITE_ID" || key === "REQUEST_TRIGGER";
35+ break;
36+ case MESSAGE_TYPE.CONFIG_BEGIN:
37+ allowed = key === "ITEM_COUNT"
38+ || key === "KEY_STATUS"
39+ || key === "DISPLAY_NAME";
40+ break;
41+ case MESSAGE_TYPE.FAVORITE:
42+ allowed = key === "ITEM_INDEX"
43+ || key === "FAVORITE_ID"
44+ || key === "SERVICE_ID"
45+ || key === "DISPLAY_NAME"
46+ || key === "STOP_LABEL"
47+ || key === "LINE_LABEL"
48+ || key === "DESTINATION_LABEL"
49+ || key === "SORT_ORDER";
50+ break;
51+ case MESSAGE_TYPE.RESULT_BEGIN:
52+ allowed = key === "FAVORITE_ID"
53+ || key === "ITEM_COUNT"
54+ || key === "FETCHED_AT"
55+ || key === "SOURCE_UPDATED_AT"
56+ || key === "FRESHNESS";
57+ break;
58+ case MESSAGE_TYPE.DEPARTURE:
59+ allowed = key === "FAVORITE_ID"
60+ || key === "ITEM_INDEX"
61+ || key === "EXPECTED_AT"
62+ || key === "AIMED_AT"
63+ || key === "MINUTES"
64+ || key === "DEPARTURE_STATUS"
65+ || key === "NEXT_INTERVAL_MINUTES";
66+ break;
67+ case MESSAGE_TYPE.RESULT_COMMIT:
68+ allowed = key === "FAVORITE_ID";
69+ break;
70+ case MESSAGE_TYPE.ERROR:
71+ allowed = key === "FAVORITE_ID"
72+ || key === "ERROR_CODE"
73+ || key === "OCCURRED_AT"
74+ || key === "RETRY_AFTER_SECONDS";
75+ break;
76+ default:
77+ break;
78+ }
79+ }
80+ if (!allowed) return false;
3081 }
3182 return true;
3283 }
@@ -44,11 +95,6 @@ function optionalUint32(message, key) {
4495 return !message.has(key) || uint32(message.get(key));
4596 }
4697
47-function aliases(message) {
48- const decoded = Object.create(null);
49- for (const entry of message) decoded[entry[0]] = entry[1];
50- return decoded;
51-}
5298
5399 export function decodeMessage(message) {
54100 if (!(message instanceof Map)
@@ -59,18 +105,13 @@ export function decodeMessage(message) {
59105 let valid = false;
60106 switch (type) {
61107 case MESSAGE_TYPE.REQUEST:
62- valid = onlyKeys(message, COMMON_KEYS.concat(["REQUEST_ID", "FAVORITE_ID", "REQUEST_TRIGGER"]))
108+ valid = onlyKeys(message, type)
63109 && string(message, "REQUEST_ID")
64110 && string(message, "FAVORITE_ID")
65111 && enumHasValue(REQUEST_TRIGGER, message.get("REQUEST_TRIGGER"));
66112 break;
67113 case MESSAGE_TYPE.CONFIG_BEGIN:
68- valid = onlyKeys(message, COMMON_KEYS.concat([
69- "REQUEST_ID",
70- "ITEM_COUNT",
71- "KEY_STATUS",
72- "DISPLAY_NAME"
73- ]))
114+ valid = onlyKeys(message, type)
74115 && string(message, "REQUEST_ID")
75116 && integer(message, "ITEM_COUNT")
76117 && message.get("ITEM_COUNT") >= 0
@@ -79,17 +120,7 @@ export function decodeMessage(message) {
79120 && (!message.has("DISPLAY_NAME") || isWireLanguage(message.get("DISPLAY_NAME")));
80121 break;
81122 case MESSAGE_TYPE.FAVORITE:
82- valid = onlyKeys(message, COMMON_KEYS.concat([
83- "REQUEST_ID",
84- "ITEM_INDEX",
85- "FAVORITE_ID",
86- "SERVICE_ID",
87- "DISPLAY_NAME",
88- "STOP_LABEL",
89- "LINE_LABEL",
90- "DESTINATION_LABEL",
91- "SORT_ORDER"
92- ]))
123+ valid = onlyKeys(message, type)
93124 && string(message, "REQUEST_ID")
94125 && integer(message, "ITEM_INDEX")
95126 && message.get("ITEM_INDEX") >= 0
@@ -105,18 +136,11 @@ export function decodeMessage(message) {
105136 && message.get("SORT_ORDER") < LIMITS.favorites;
106137 break;
107138 case MESSAGE_TYPE.CONFIG_COMMIT:
108- valid = onlyKeys(message, COMMON_KEYS.concat(["REQUEST_ID"]))
139+ valid = onlyKeys(message, type)
109140 && string(message, "REQUEST_ID");
110141 break;
111142 case MESSAGE_TYPE.RESULT_BEGIN:
112- valid = onlyKeys(message, COMMON_KEYS.concat([
113- "REQUEST_ID",
114- "FAVORITE_ID",
115- "ITEM_COUNT",
116- "FETCHED_AT",
117- "SOURCE_UPDATED_AT",
118- "FRESHNESS"
119- ]))
143+ valid = onlyKeys(message, type)
120144 && string(message, "REQUEST_ID")
121145 && string(message, "FAVORITE_ID")
122146 && integer(message, "ITEM_COUNT")
@@ -129,16 +153,7 @@ export function decodeMessage(message) {
129153 && message.get("FRESHNESS") < FRESHNESS.length;
130154 break;
131155 case MESSAGE_TYPE.DEPARTURE:
132- valid = onlyKeys(message, COMMON_KEYS.concat([
133- "REQUEST_ID",
134- "FAVORITE_ID",
135- "ITEM_INDEX",
136- "EXPECTED_AT",
137- "AIMED_AT",
138- "MINUTES",
139- "DEPARTURE_STATUS",
140- "NEXT_INTERVAL_MINUTES"
141- ]))
156+ valid = onlyKeys(message, type)
142157 && string(message, "REQUEST_ID")
143158 && string(message, "FAVORITE_ID")
144159 && integer(message, "ITEM_INDEX")
@@ -158,18 +173,12 @@ export function decodeMessage(message) {
158173 && message.get("NEXT_INTERVAL_MINUTES") <= 1440));
159174 break;
160175 case MESSAGE_TYPE.RESULT_COMMIT:
161- valid = onlyKeys(message, COMMON_KEYS.concat(["REQUEST_ID", "FAVORITE_ID"]))
176+ valid = onlyKeys(message, type)
162177 && string(message, "REQUEST_ID")
163178 && string(message, "FAVORITE_ID");
164179 break;
165180 case MESSAGE_TYPE.ERROR:
166- valid = onlyKeys(message, COMMON_KEYS.concat([
167- "REQUEST_ID",
168- "FAVORITE_ID",
169- "ERROR_CODE",
170- "OCCURRED_AT",
171- "RETRY_AFTER_SECONDS"
172- ]))
181+ valid = onlyKeys(message, type)
173182 && string(message, "REQUEST_ID")
174183 && (!message.has("FAVORITE_ID") || string(message, "FAVORITE_ID"))
175184 && integer(message, "ERROR_CODE")
@@ -182,7 +191,7 @@ export function decodeMessage(message) {
182191 return null;
183192 }
184193 if (!valid) return null;
185- return aliases(message);
194+ return message;
186195 }
187196
188197 export function encodeRequest(request) {
@@ -261,6 +270,38 @@ function copyError(error) {
261270 return copy;
262271 }
263272
273+function sharedFavoriteString(stage, committed, key, value) {
274+ for (let index = 0; index < stage.favorites.length; index += 1) {
275+ const candidate = stage.favorites[index][key];
276+ if (candidate === value) return candidate;
277+ }
278+ if (committed) {
279+ for (let index = 0; index < committed.favorites.length; index += 1) {
280+ const candidate = committed.favorites[index][key];
281+ if (candidate === value) return candidate;
282+ }
283+ }
284+ return value;
285+}
286+
287+function requestSequence(prefix, requestId) {
288+ if (!prefix || requestId.length <= prefix.length) return 0;
289+ let sequence = 0;
290+ for (let index = 0; index < requestId.length; index += 1) {
291+ const code = requestId.charCodeAt(index);
292+ if (index < prefix.length) {
293+ if (code !== prefix.charCodeAt(index)) return 0;
294+ } else {
295+ if (code < 48
296+ || code > 57
297+ || (index === prefix.length && code === 48)) return 0;
298+ sequence = sequence * 10 + code - 48;
299+ }
300+ }
301+ return sequence;
302+}
303+
304+
264305 export class ProtocolReceiver {
265306 constructor(initialConfiguration = null) {
266307 this.configuration = null;
@@ -268,31 +309,56 @@ export class ProtocolReceiver {
268309 this.error = null;
269310 this.configStage = null;
270311 this.resultStage = null;
271- this.expected = null;
272- this.refreshCandidate = null;
312+ this.prefix = null;
313+ this.favoriteId = null;
314+ this.latestSeq = 0;
315+ this.committedSeq = 0;
316+ this.refreshSeq = 0;
273317 if (initialConfiguration) this.restoreConfiguration(initialConfiguration);
274318 }
275319
276- restoreConfiguration(configuration) {
277- this.configuration = copyConfiguration(configuration);
320+ restoreState(state, copy = true) {
321+ const source = state || {};
322+ this.configuration = copy ? copyConfiguration(source.configuration) : source.configuration || null;
323+ this.result = copy ? copyResult(source.result) : source.result || null;
324+ this.error = copy ? copyError(source.error) : source.error || null;
325+ this.discardStaging();
326+ this.cancelExpectedResponse();
327+ }
328+
329+ restoreConfiguration(configuration, copy = true) {
330+ this.configuration = copy ? copyConfiguration(configuration) : configuration || null;
278331 this.result = null;
279332 this.error = null;
280333 this.discardStaging();
281334 this.cancelExpectedResponse();
282335 }
283336
284- expectResponse(requestId, favoriteId) {
337+ expectResponse(requestId, favoriteId, prefix, sequence) {
285338 if (!boundedString(requestId, LIMITS.idUtf8Bytes)
286- || !boundedString(favoriteId, LIMITS.idUtf8Bytes)) return false;
287- this.expected = { requestId, favoriteId, refresh: false };
288- this.refreshCandidate = null;
289- this.resultStage = null;
339+ || !boundedString(favoriteId, LIMITS.idUtf8Bytes)
340+ || typeof prefix !== "string"
341+ || sequence === 0
342+ || requestSequence(prefix, requestId) !== sequence) return false;
343+ if (this.latestSeq === 0) {
344+ this.prefix = prefix;
345+ this.favoriteId = favoriteId;
346+ this.committedSeq = sequence - 1;
347+ } else if (prefix !== this.prefix
348+ || favoriteId !== this.favoriteId
349+ || sequence <= this.latestSeq) {
350+ return false;
351+ }
352+ this.latestSeq = sequence;
290353 return true;
291354 }
292355
293356 cancelExpectedResponse() {
294- this.expected = null;
295- this.refreshCandidate = null;
357+ this.prefix = null;
358+ this.favoriteId = null;
359+ this.latestSeq = 0;
360+ this.committedSeq = 0;
361+ this.refreshSeq = 0;
296362 this.resultStage = null;
297363 }
298364
@@ -309,6 +375,14 @@ export class ProtocolReceiver {
309375 };
310376 }
311377
378+ borrowState() {
379+ return {
380+ configuration: this.configuration,
381+ result: this.result,
382+ error: this.error
383+ };
384+ }
385+
312386 receive(message) {
313387 const decoded = decodeMessage(message);
314388 if (!decoded) {
@@ -316,14 +390,14 @@ export class ProtocolReceiver {
316390 return RECEIVE_RESULT.REJECTED;
317391 }
318392
319- const type = decoded.MESSAGE_TYPE;
320- const requestId = decoded.REQUEST_ID || "";
393+ const type = decoded.get("MESSAGE_TYPE");
394+ const requestId = decoded.get("REQUEST_ID") || "";
321395 if (type === MESSAGE_TYPE.CONFIG_BEGIN) {
322396 this.configStage = {
323397 requestId,
324- count: decoded.ITEM_COUNT,
325- keyStatus: decoded.KEY_STATUS,
326- language: isWireLanguage(decoded.DISPLAY_NAME) ? decoded.DISPLAY_NAME : LANGUAGE.EN,
398+ count: decoded.get("ITEM_COUNT"),
399+ keyStatus: decoded.get("KEY_STATUS"),
400+ language: decoded.get("DISPLAY_NAME") === LANGUAGE.FR ? LANGUAGE.FR : LANGUAGE.EN,
327401 favorites: []
328402 };
329403 return RECEIVE_RESULT.STAGED;
@@ -334,26 +408,71 @@ export class ProtocolReceiver {
334408 let duplicate = false;
335409 if (stage) {
336410 for (let index = 0; index < stage.favorites.length; index += 1) {
337- if (stage.favorites[index].id === decoded.FAVORITE_ID) duplicate = true;
411+ if (stage.favorites[index].id === decoded.get("FAVORITE_ID")) duplicate = true;
338412 }
339413 }
340414 if (!stage
341415 || stage.requestId !== requestId
342- || decoded.ITEM_INDEX !== stage.favorites.length
416+ || decoded.get("ITEM_INDEX") !== stage.favorites.length
343417 || stage.favorites.length >= stage.count
344418 || duplicate) {
345419 this.configStage = null;
346420 return RECEIVE_RESULT.REJECTED;
347421 }
422+ const committed = this.configuration;
423+ const previous = committed && committed.favorites[stage.favorites.length];
424+ if (previous
425+ && previous.id === decoded.get("FAVORITE_ID")
426+ && previous.serviceId === decoded.get("SERVICE_ID")
427+ && previous.displayName === decoded.get("DISPLAY_NAME")
428+ && previous.stopLabel === decoded.get("STOP_LABEL")
429+ && previous.lineLabel === decoded.get("LINE_LABEL")
430+ && previous.destinationLabel === decoded.get("DESTINATION_LABEL")
431+ && previous.sortOrder === decoded.get("SORT_ORDER")) {
432+ stage.favorites.push(previous);
433+ return RECEIVE_RESULT.STAGED;
434+ }
348435 const favorite = {
349- id: decoded.FAVORITE_ID,
350- serviceId: decoded.SERVICE_ID,
351- stopLabel: decoded.STOP_LABEL,
352- lineLabel: decoded.LINE_LABEL,
353- destinationLabel: decoded.DESTINATION_LABEL,
354- sortOrder: decoded.SORT_ORDER
436+ id: sharedFavoriteString(
437+ stage,
438+ committed,
439+ "id",
440+ decoded.get("FAVORITE_ID")
441+ ),
442+ serviceId: sharedFavoriteString(
443+ stage,
444+ committed,
445+ "serviceId",
446+ decoded.get("SERVICE_ID")
447+ ),
448+ stopLabel: sharedFavoriteString(
449+ stage,
450+ committed,
451+ "stopLabel",
452+ decoded.get("STOP_LABEL")
453+ ),
454+ lineLabel: sharedFavoriteString(
455+ stage,
456+ committed,
457+ "lineLabel",
458+ decoded.get("LINE_LABEL")
459+ ),
460+ destinationLabel: sharedFavoriteString(
461+ stage,
462+ committed,
463+ "destinationLabel",
464+ decoded.get("DESTINATION_LABEL")
465+ ),
466+ sortOrder: decoded.get("SORT_ORDER")
355467 };
356- if (decoded.DISPLAY_NAME !== undefined) favorite.displayName = decoded.DISPLAY_NAME;
468+ if (decoded.get("DISPLAY_NAME") !== undefined) {
469+ favorite.displayName = sharedFavoriteString(
470+ stage,
471+ committed,
472+ "displayName",
473+ decoded.get("DISPLAY_NAME")
474+ );
475+ }
357476 stage.favorites.push(favorite);
358477 return RECEIVE_RESULT.STAGED;
359478 }
@@ -366,12 +485,21 @@ export class ProtocolReceiver {
366485 this.configStage = null;
367486 return RECEIVE_RESULT.REJECTED;
368487 }
488+ let resultStillConfigured = false;
489+ if (this.result) {
490+ for (let index = 0; index < stage.favorites.length; index += 1) {
491+ if (stage.favorites[index].id === this.result.favoriteId) {
492+ resultStillConfigured = true;
493+ break;
494+ }
495+ }
496+ }
369497 this.configuration = {
370498 keyStatus: stage.keyStatus,
371499 language: stage.language,
372- favorites: stage.favorites.map(copyFavorite)
500+ favorites: stage.favorites
373501 };
374- this.result = null;
502+ if (!resultStillConfigured) this.result = null;
375503 this.error = null;
376504 this.configStage = null;
377505 this.cancelExpectedResponse();
@@ -379,37 +507,43 @@ export class ProtocolReceiver {
379507 }
380508
381509 if (type === MESSAGE_TYPE.REQUEST) {
382- const candidate = this.refreshCandidate;
383- if (this.expected
384- || !candidate
385- || candidate.requestId !== requestId
386- || candidate.favoriteId !== decoded.FAVORITE_ID) {
510+ const favoriteId = decoded.get("FAVORITE_ID");
511+ const sequence = favoriteId === this.favoriteId
512+ ? requestSequence(this.prefix, requestId)
513+ : 0;
514+ if (sequence === 0
515+ || sequence < this.committedSeq
516+ || this.refreshSeq !== -sequence
517+ || !this.result
518+ || this.result.requestId !== requestId
519+ || this.result.favoriteId !== favoriteId) {
387520 return RECEIVE_RESULT.REJECTED;
388521 }
389- this.expected = {
390- requestId,
391- favoriteId: decoded.FAVORITE_ID,
392- refresh: true
393- };
394- this.refreshCandidate = null;
522+ this.refreshSeq = sequence;
395523 this.resultStage = null;
396524 return RECEIVE_RESULT.STAGED;
397525 }
398526
399527 if (type === MESSAGE_TYPE.RESULT_BEGIN) {
400- if (!this.expected
401- || this.expected.requestId !== requestId
402- || this.expected.favoriteId !== decoded.FAVORITE_ID) {
528+ const favoriteId = decoded.get("FAVORITE_ID");
529+ const sequence = favoriteId === this.favoriteId
530+ ? requestSequence(this.prefix, requestId)
531+ : 0;
532+ const refreshing = sequence !== 0
533+ && sequence === this.refreshSeq
534+ && sequence >= this.committedSeq;
535+ if (!refreshing
536+ && !(sequence > this.committedSeq && sequence <= this.latestSeq)) {
403537 this.resultStage = null;
404538 return RECEIVE_RESULT.REJECTED;
405539 }
406540 this.resultStage = {
407541 requestId,
408- favoriteId: decoded.FAVORITE_ID,
409- count: decoded.ITEM_COUNT,
410- fetchedAt: decoded.FETCHED_AT,
411- sourceUpdatedAt: decoded.SOURCE_UPDATED_AT,
412- freshness: FRESHNESS[decoded.FRESHNESS],
542+ favoriteId,
543+ count: decoded.get("ITEM_COUNT"),
544+ fetchedAt: decoded.get("FETCHED_AT"),
545+ sourceUpdatedAt: decoded.get("SOURCE_UPDATED_AT"),
546+ freshness: FRESHNESS[decoded.get("FRESHNESS")],
413547 departures: []
414548 };
415549 return RECEIVE_RESULT.STAGED;
@@ -418,24 +552,21 @@ export class ProtocolReceiver {
418552 if (type === MESSAGE_TYPE.DEPARTURE) {
419553 const stage = this.resultStage;
420554 if (!stage
421- || !this.expected
422555 || stage.requestId !== requestId
423- || stage.favoriteId !== decoded.FAVORITE_ID
424- || this.expected.requestId !== requestId
425- || this.expected.favoriteId !== decoded.FAVORITE_ID
426- || decoded.ITEM_INDEX !== stage.departures.length
556+ || stage.favoriteId !== decoded.get("FAVORITE_ID")
557+ || decoded.get("ITEM_INDEX") !== stage.departures.length
427558 || stage.departures.length >= stage.count) {
428559 this.resultStage = null;
429560 return RECEIVE_RESULT.REJECTED;
430561 }
431562 const departure = {
432- expectedAt: decoded.EXPECTED_AT,
433- minutes: decoded.MINUTES,
434- status: DEPARTURE_STATUS[decoded.DEPARTURE_STATUS]
563+ expectedAt: decoded.get("EXPECTED_AT"),
564+ minutes: decoded.get("MINUTES"),
565+ status: DEPARTURE_STATUS[decoded.get("DEPARTURE_STATUS")]
435566 };
436- if (decoded.AIMED_AT !== undefined) departure.aimedAt = decoded.AIMED_AT;
437- if (decoded.NEXT_INTERVAL_MINUTES !== undefined) {
438- departure.nextIntervalMinutes = decoded.NEXT_INTERVAL_MINUTES;
567+ if (decoded.get("AIMED_AT") !== undefined) departure.aimedAt = decoded.get("AIMED_AT");
568+ if (decoded.get("NEXT_INTERVAL_MINUTES") !== undefined) {
569+ departure.nextIntervalMinutes = decoded.get("NEXT_INTERVAL_MINUTES");
439570 }
440571 stage.departures.push(departure);
441572 return RECEIVE_RESULT.STAGED;
@@ -443,45 +574,55 @@ export class ProtocolReceiver {
443574
444575 if (type === MESSAGE_TYPE.RESULT_COMMIT) {
445576 const stage = this.resultStage;
577+ const favoriteId = decoded.get("FAVORITE_ID");
578+ const sequence = favoriteId === this.favoriteId
579+ ? requestSequence(this.prefix, requestId)
580+ : 0;
581+ const refreshing = sequence !== 0
582+ && sequence === this.refreshSeq
583+ && sequence >= this.committedSeq;
446584 if (!stage
447- || !this.expected
585+ || (!refreshing
586+ && !(sequence > this.committedSeq && sequence <= this.latestSeq))
448587 || stage.requestId !== requestId
449- || stage.favoriteId !== decoded.FAVORITE_ID
450- || this.expected.requestId !== requestId
451- || this.expected.favoriteId !== decoded.FAVORITE_ID
588+ || stage.favoriteId !== favoriteId
452589 || stage.departures.length !== stage.count) {
453590 this.resultStage = null;
454591 return RECEIVE_RESULT.REJECTED;
455592 }
456- const refresh = this.expected.refresh;
457- this.result = copyResult(stage);
593+ this.result = stage;
458594 this.error = null;
459- this.expected = null;
595+ if (sequence > this.committedSeq) this.committedSeq = sequence;
460596 this.resultStage = null;
461- this.refreshCandidate = refresh
462- ? null
463- : { requestId, favoriteId: decoded.FAVORITE_ID };
597+ this.refreshSeq = refreshing ? 0 : -sequence;
464598 return RECEIVE_RESULT.RESULT_COMMITTED;
465599 }
466600
467601 if (type === MESSAGE_TYPE.ERROR) {
468- if (!this.expected
469- || this.expected.requestId !== requestId
470- || (decoded.FAVORITE_ID !== undefined
471- && this.expected.favoriteId !== decoded.FAVORITE_ID)) {
602+ const favoriteId = decoded.get("FAVORITE_ID");
603+ const sequence = (favoriteId === undefined || favoriteId === this.favoriteId)
604+ ? requestSequence(this.prefix, requestId)
605+ : 0;
606+ const refreshing = sequence !== 0
607+ && sequence === this.refreshSeq
608+ && sequence >= this.committedSeq;
609+ if (!refreshing
610+ && !(sequence > this.committedSeq && sequence <= this.latestSeq)) {
472611 this.resultStage = null;
473612 return RECEIVE_RESULT.REJECTED;
474613 }
475614 this.error = {
476615 requestId,
477- code: ERROR_CODE[decoded.ERROR_CODE],
478- occurredAt: decoded.OCCURRED_AT
616+ code: ERROR_CODE[decoded.get("ERROR_CODE")],
617+ occurredAt: decoded.get("OCCURRED_AT")
479618 };
480- if (decoded.FAVORITE_ID !== undefined) this.error.favoriteId = decoded.FAVORITE_ID;
481- if (decoded.RETRY_AFTER_SECONDS !== undefined) {
482- this.error.retryAfterSeconds = decoded.RETRY_AFTER_SECONDS;
619+ if (favoriteId !== undefined) this.error.favoriteId = favoriteId;
620+ if (decoded.get("RETRY_AFTER_SECONDS") !== undefined) {
621+ this.error.retryAfterSeconds = decoded.get("RETRY_AFTER_SECONDS");
483622 }
484- this.cancelExpectedResponse();
623+ if (sequence > this.committedSeq) this.committedSeq = sequence;
624+ this.refreshSeq = 0;
625+ this.resultStage = null;
485626 return RECEIVE_RESULT.ERROR_COMMITTED;
486627 }
487628
@@ -22,11 +22,62 @@ export const RECEIVE_RESULT = Object.freeze({
22 ERROR_COMMITTED: 422 ERROR_COMMITTED: 4
23 });23 });
24 24
25-const COMMON_KEYS = Object.freeze(["SCHEMA_VERSION", "MESSAGE_TYPE"]);25+function onlyKeys(message, type) {
26-
27-function onlyKeys(message, allowed) {
28 for (const key of message.keys()) {26 for (const key of message.keys()) {
29- if (typeof key !== "string" || allowed.indexOf(key) === -1) return false;27+ if (typeof key !== "string") return false;
28+ let allowed = key === "SCHEMA_VERSION"
29+ || key === "MESSAGE_TYPE"
30+ || key === "REQUEST_ID";
31+ if (!allowed) {
32+ switch (type) {
33+ case MESSAGE_TYPE.REQUEST:
34+ allowed = key === "FAVORITE_ID" || key === "REQUEST_TRIGGER";
35+ break;
36+ case MESSAGE_TYPE.CONFIG_BEGIN:
37+ allowed = key === "ITEM_COUNT"
38+ || key === "KEY_STATUS"
39+ || key === "DISPLAY_NAME";
40+ break;
41+ case MESSAGE_TYPE.FAVORITE:
42+ allowed = key === "ITEM_INDEX"
43+ || key === "FAVORITE_ID"
44+ || key === "SERVICE_ID"
45+ || key === "DISPLAY_NAME"
46+ || key === "STOP_LABEL"
47+ || key === "LINE_LABEL"
48+ || key === "DESTINATION_LABEL"
49+ || key === "SORT_ORDER";
50+ break;
51+ case MESSAGE_TYPE.RESULT_BEGIN:
52+ allowed = key === "FAVORITE_ID"
53+ || key === "ITEM_COUNT"
54+ || key === "FETCHED_AT"
55+ || key === "SOURCE_UPDATED_AT"
56+ || key === "FRESHNESS";
57+ break;
58+ case MESSAGE_TYPE.DEPARTURE:
59+ allowed = key === "FAVORITE_ID"
60+ || key === "ITEM_INDEX"
61+ || key === "EXPECTED_AT"
62+ || key === "AIMED_AT"
63+ || key === "MINUTES"
64+ || key === "DEPARTURE_STATUS"
65+ || key === "NEXT_INTERVAL_MINUTES";
66+ break;
67+ case MESSAGE_TYPE.RESULT_COMMIT:
68+ allowed = key === "FAVORITE_ID";
69+ break;
70+ case MESSAGE_TYPE.ERROR:
71+ allowed = key === "FAVORITE_ID"
72+ || key === "ERROR_CODE"
73+ || key === "OCCURRED_AT"
74+ || key === "RETRY_AFTER_SECONDS";
75+ break;
76+ default:
77+ break;
78+ }
79+ }
80+ if (!allowed) return false;
30 }81 }
31 return true;82 return true;
32 }83 }
@@ -44,11 +95,6 @@ function optionalUint32(message, key) {
44 return !message.has(key) || uint32(message.get(key));95 return !message.has(key) || uint32(message.get(key));
45 }96 }
46 97
47-function aliases(message) {
48- const decoded = Object.create(null);
49- for (const entry of message) decoded[entry[0]] = entry[1];
50- return decoded;
51-}
52 98
53 export function decodeMessage(message) {99 export function decodeMessage(message) {
54 if (!(message instanceof Map)100 if (!(message instanceof Map)
@@ -59,18 +105,13 @@ export function decodeMessage(message) {
59 let valid = false;105 let valid = false;
60 switch (type) {106 switch (type) {
61 case MESSAGE_TYPE.REQUEST:107 case MESSAGE_TYPE.REQUEST:
62- valid = onlyKeys(message, COMMON_KEYS.concat(["REQUEST_ID", "FAVORITE_ID", "REQUEST_TRIGGER"]))108+ valid = onlyKeys(message, type)
63 && string(message, "REQUEST_ID")109 && string(message, "REQUEST_ID")
64 && string(message, "FAVORITE_ID")110 && string(message, "FAVORITE_ID")
65 && enumHasValue(REQUEST_TRIGGER, message.get("REQUEST_TRIGGER"));111 && enumHasValue(REQUEST_TRIGGER, message.get("REQUEST_TRIGGER"));
66 break;112 break;
67 case MESSAGE_TYPE.CONFIG_BEGIN:113 case MESSAGE_TYPE.CONFIG_BEGIN:
68- valid = onlyKeys(message, COMMON_KEYS.concat([114+ valid = onlyKeys(message, type)
69- "REQUEST_ID",
70- "ITEM_COUNT",
71- "KEY_STATUS",
72- "DISPLAY_NAME"
73- ]))
74 && string(message, "REQUEST_ID")115 && string(message, "REQUEST_ID")
75 && integer(message, "ITEM_COUNT")116 && integer(message, "ITEM_COUNT")
76 && message.get("ITEM_COUNT") >= 0117 && message.get("ITEM_COUNT") >= 0
@@ -79,17 +120,7 @@ export function decodeMessage(message) {
79 && (!message.has("DISPLAY_NAME") || isWireLanguage(message.get("DISPLAY_NAME")));120 && (!message.has("DISPLAY_NAME") || isWireLanguage(message.get("DISPLAY_NAME")));
80 break;121 break;
81 case MESSAGE_TYPE.FAVORITE:122 case MESSAGE_TYPE.FAVORITE:
82- valid = onlyKeys(message, COMMON_KEYS.concat([123+ valid = onlyKeys(message, type)
83- "REQUEST_ID",
84- "ITEM_INDEX",
85- "FAVORITE_ID",
86- "SERVICE_ID",
87- "DISPLAY_NAME",
88- "STOP_LABEL",
89- "LINE_LABEL",
90- "DESTINATION_LABEL",
91- "SORT_ORDER"
92- ]))
93 && string(message, "REQUEST_ID")124 && string(message, "REQUEST_ID")
94 && integer(message, "ITEM_INDEX")125 && integer(message, "ITEM_INDEX")
95 && message.get("ITEM_INDEX") >= 0126 && message.get("ITEM_INDEX") >= 0
@@ -105,18 +136,11 @@ export function decodeMessage(message) {
105 && message.get("SORT_ORDER") < LIMITS.favorites;136 && message.get("SORT_ORDER") < LIMITS.favorites;
106 break;137 break;
107 case MESSAGE_TYPE.CONFIG_COMMIT:138 case MESSAGE_TYPE.CONFIG_COMMIT:
108- valid = onlyKeys(message, COMMON_KEYS.concat(["REQUEST_ID"]))139+ valid = onlyKeys(message, type)
109 && string(message, "REQUEST_ID");140 && string(message, "REQUEST_ID");
110 break;141 break;
111 case MESSAGE_TYPE.RESULT_BEGIN:142 case MESSAGE_TYPE.RESULT_BEGIN:
112- valid = onlyKeys(message, COMMON_KEYS.concat([143+ valid = onlyKeys(message, type)
113- "REQUEST_ID",
114- "FAVORITE_ID",
115- "ITEM_COUNT",
116- "FETCHED_AT",
117- "SOURCE_UPDATED_AT",
118- "FRESHNESS"
119- ]))
120 && string(message, "REQUEST_ID")144 && string(message, "REQUEST_ID")
121 && string(message, "FAVORITE_ID")145 && string(message, "FAVORITE_ID")
122 && integer(message, "ITEM_COUNT")146 && integer(message, "ITEM_COUNT")
@@ -129,16 +153,7 @@ export function decodeMessage(message) {
129 && message.get("FRESHNESS") < FRESHNESS.length;153 && message.get("FRESHNESS") < FRESHNESS.length;
130 break;154 break;
131 case MESSAGE_TYPE.DEPARTURE:155 case MESSAGE_TYPE.DEPARTURE:
132- valid = onlyKeys(message, COMMON_KEYS.concat([156+ valid = onlyKeys(message, type)
133- "REQUEST_ID",
134- "FAVORITE_ID",
135- "ITEM_INDEX",
136- "EXPECTED_AT",
137- "AIMED_AT",
138- "MINUTES",
139- "DEPARTURE_STATUS",
140- "NEXT_INTERVAL_MINUTES"
141- ]))
142 && string(message, "REQUEST_ID")157 && string(message, "REQUEST_ID")
143 && string(message, "FAVORITE_ID")158 && string(message, "FAVORITE_ID")
144 && integer(message, "ITEM_INDEX")159 && integer(message, "ITEM_INDEX")
@@ -158,18 +173,12 @@ export function decodeMessage(message) {
158 && message.get("NEXT_INTERVAL_MINUTES") <= 1440));173 && message.get("NEXT_INTERVAL_MINUTES") <= 1440));
159 break;174 break;
160 case MESSAGE_TYPE.RESULT_COMMIT:175 case MESSAGE_TYPE.RESULT_COMMIT:
161- valid = onlyKeys(message, COMMON_KEYS.concat(["REQUEST_ID", "FAVORITE_ID"]))176+ valid = onlyKeys(message, type)
162 && string(message, "REQUEST_ID")177 && string(message, "REQUEST_ID")
163 && string(message, "FAVORITE_ID");178 && string(message, "FAVORITE_ID");
164 break;179 break;
165 case MESSAGE_TYPE.ERROR:180 case MESSAGE_TYPE.ERROR:
166- valid = onlyKeys(message, COMMON_KEYS.concat([181+ valid = onlyKeys(message, type)
167- "REQUEST_ID",
168- "FAVORITE_ID",
169- "ERROR_CODE",
170- "OCCURRED_AT",
171- "RETRY_AFTER_SECONDS"
172- ]))
173 && string(message, "REQUEST_ID")182 && string(message, "REQUEST_ID")
174 && (!message.has("FAVORITE_ID") || string(message, "FAVORITE_ID"))183 && (!message.has("FAVORITE_ID") || string(message, "FAVORITE_ID"))
175 && integer(message, "ERROR_CODE")184 && integer(message, "ERROR_CODE")
@@ -182,7 +191,7 @@ export function decodeMessage(message) {
182 return null;191 return null;
183 }192 }
184 if (!valid) return null;193 if (!valid) return null;
185- return aliases(message);194+ return message;
186 }195 }
187 196
188 export function encodeRequest(request) {197 export function encodeRequest(request) {
@@ -261,6 +270,38 @@ function copyError(error) {
261 return copy;270 return copy;
262 }271 }
263 272
273+function sharedFavoriteString(stage, committed, key, value) {
274+ for (let index = 0; index < stage.favorites.length; index += 1) {
275+ const candidate = stage.favorites[index][key];
276+ if (candidate === value) return candidate;
277+ }
278+ if (committed) {
279+ for (let index = 0; index < committed.favorites.length; index += 1) {
280+ const candidate = committed.favorites[index][key];
281+ if (candidate === value) return candidate;
282+ }
283+ }
284+ return value;
285+}
286+
287+function requestSequence(prefix, requestId) {
288+ if (!prefix || requestId.length <= prefix.length) return 0;
289+ let sequence = 0;
290+ for (let index = 0; index < requestId.length; index += 1) {
291+ const code = requestId.charCodeAt(index);
292+ if (index < prefix.length) {
293+ if (code !== prefix.charCodeAt(index)) return 0;
294+ } else {
295+ if (code < 48
296+ || code > 57
297+ || (index === prefix.length && code === 48)) return 0;
298+ sequence = sequence * 10 + code - 48;
299+ }
300+ }
301+ return sequence;
302+}
303+
304+
264 export class ProtocolReceiver {305 export class ProtocolReceiver {
265 constructor(initialConfiguration = null) {306 constructor(initialConfiguration = null) {
266 this.configuration = null;307 this.configuration = null;
@@ -268,31 +309,56 @@ export class ProtocolReceiver {
268 this.error = null;309 this.error = null;
269 this.configStage = null;310 this.configStage = null;
270 this.resultStage = null;311 this.resultStage = null;
271- this.expected = null;312+ this.prefix = null;
272- this.refreshCandidate = null;313+ this.favoriteId = null;
314+ this.latestSeq = 0;
315+ this.committedSeq = 0;
316+ this.refreshSeq = 0;
273 if (initialConfiguration) this.restoreConfiguration(initialConfiguration);317 if (initialConfiguration) this.restoreConfiguration(initialConfiguration);
274 }318 }
275 319
276- restoreConfiguration(configuration) {320+ restoreState(state, copy = true) {
277- this.configuration = copyConfiguration(configuration);321+ const source = state || {};
322+ this.configuration = copy ? copyConfiguration(source.configuration) : source.configuration || null;
323+ this.result = copy ? copyResult(source.result) : source.result || null;
324+ this.error = copy ? copyError(source.error) : source.error || null;
325+ this.discardStaging();
326+ this.cancelExpectedResponse();
327+ }
328+
329+ restoreConfiguration(configuration, copy = true) {
330+ this.configuration = copy ? copyConfiguration(configuration) : configuration || null;
278 this.result = null;331 this.result = null;
279 this.error = null;332 this.error = null;
280 this.discardStaging();333 this.discardStaging();
281 this.cancelExpectedResponse();334 this.cancelExpectedResponse();
282 }335 }
283 336
284- expectResponse(requestId, favoriteId) {337+ expectResponse(requestId, favoriteId, prefix, sequence) {
285 if (!boundedString(requestId, LIMITS.idUtf8Bytes)338 if (!boundedString(requestId, LIMITS.idUtf8Bytes)
286- || !boundedString(favoriteId, LIMITS.idUtf8Bytes)) return false;339+ || !boundedString(favoriteId, LIMITS.idUtf8Bytes)
287- this.expected = { requestId, favoriteId, refresh: false };340+ || typeof prefix !== "string"
288- this.refreshCandidate = null;341+ || sequence === 0
289- this.resultStage = null;342+ || requestSequence(prefix, requestId) !== sequence) return false;
343+ if (this.latestSeq === 0) {
344+ this.prefix = prefix;
345+ this.favoriteId = favoriteId;
346+ this.committedSeq = sequence - 1;
347+ } else if (prefix !== this.prefix
348+ || favoriteId !== this.favoriteId
349+ || sequence <= this.latestSeq) {
350+ return false;
351+ }
352+ this.latestSeq = sequence;
290 return true;353 return true;
291 }354 }
292 355
293 cancelExpectedResponse() {356 cancelExpectedResponse() {
294- this.expected = null;357+ this.prefix = null;
295- this.refreshCandidate = null;358+ this.favoriteId = null;
359+ this.latestSeq = 0;
360+ this.committedSeq = 0;
361+ this.refreshSeq = 0;
296 this.resultStage = null;362 this.resultStage = null;
297 }363 }
298 364
@@ -309,6 +375,14 @@ export class ProtocolReceiver {
309 };375 };
310 }376 }
311 377
378+ borrowState() {
379+ return {
380+ configuration: this.configuration,
381+ result: this.result,
382+ error: this.error
383+ };
384+ }
385+
312 receive(message) {386 receive(message) {
313 const decoded = decodeMessage(message);387 const decoded = decodeMessage(message);
314 if (!decoded) {388 if (!decoded) {
@@ -316,14 +390,14 @@ export class ProtocolReceiver {
316 return RECEIVE_RESULT.REJECTED;390 return RECEIVE_RESULT.REJECTED;
317 }391 }
318 392
319- const type = decoded.MESSAGE_TYPE;393+ const type = decoded.get("MESSAGE_TYPE");
320- const requestId = decoded.REQUEST_ID || "";394+ const requestId = decoded.get("REQUEST_ID") || "";
321 if (type === MESSAGE_TYPE.CONFIG_BEGIN) {395 if (type === MESSAGE_TYPE.CONFIG_BEGIN) {
322 this.configStage = {396 this.configStage = {
323 requestId,397 requestId,
324- count: decoded.ITEM_COUNT,398+ count: decoded.get("ITEM_COUNT"),
325- keyStatus: decoded.KEY_STATUS,399+ keyStatus: decoded.get("KEY_STATUS"),
326- language: isWireLanguage(decoded.DISPLAY_NAME) ? decoded.DISPLAY_NAME : LANGUAGE.EN,400+ language: decoded.get("DISPLAY_NAME") === LANGUAGE.FR ? LANGUAGE.FR : LANGUAGE.EN,
327 favorites: []401 favorites: []
328 };402 };
329 return RECEIVE_RESULT.STAGED;403 return RECEIVE_RESULT.STAGED;
@@ -334,26 +408,71 @@ export class ProtocolReceiver {
334 let duplicate = false;408 let duplicate = false;
335 if (stage) {409 if (stage) {
336 for (let index = 0; index < stage.favorites.length; index += 1) {410 for (let index = 0; index < stage.favorites.length; index += 1) {
337- if (stage.favorites[index].id === decoded.FAVORITE_ID) duplicate = true;411+ if (stage.favorites[index].id === decoded.get("FAVORITE_ID")) duplicate = true;
338 }412 }
339 }413 }
340 if (!stage414 if (!stage
341 || stage.requestId !== requestId415 || stage.requestId !== requestId
342- || decoded.ITEM_INDEX !== stage.favorites.length416+ || decoded.get("ITEM_INDEX") !== stage.favorites.length
343 || stage.favorites.length >= stage.count417 || stage.favorites.length >= stage.count
344 || duplicate) {418 || duplicate) {
345 this.configStage = null;419 this.configStage = null;
346 return RECEIVE_RESULT.REJECTED;420 return RECEIVE_RESULT.REJECTED;
347 }421 }
422+ const committed = this.configuration;
423+ const previous = committed && committed.favorites[stage.favorites.length];
424+ if (previous
425+ && previous.id === decoded.get("FAVORITE_ID")
426+ && previous.serviceId === decoded.get("SERVICE_ID")
427+ && previous.displayName === decoded.get("DISPLAY_NAME")
428+ && previous.stopLabel === decoded.get("STOP_LABEL")
429+ && previous.lineLabel === decoded.get("LINE_LABEL")
430+ && previous.destinationLabel === decoded.get("DESTINATION_LABEL")
431+ && previous.sortOrder === decoded.get("SORT_ORDER")) {
432+ stage.favorites.push(previous);
433+ return RECEIVE_RESULT.STAGED;
434+ }
348 const favorite = {435 const favorite = {
349- id: decoded.FAVORITE_ID,436+ id: sharedFavoriteString(
350- serviceId: decoded.SERVICE_ID,437+ stage,
351- stopLabel: decoded.STOP_LABEL,438+ committed,
352- lineLabel: decoded.LINE_LABEL,439+ "id",
353- destinationLabel: decoded.DESTINATION_LABEL,440+ decoded.get("FAVORITE_ID")
354- sortOrder: decoded.SORT_ORDER441+ ),
442+ serviceId: sharedFavoriteString(
443+ stage,
444+ committed,
445+ "serviceId",
446+ decoded.get("SERVICE_ID")
447+ ),
448+ stopLabel: sharedFavoriteString(
449+ stage,
450+ committed,
451+ "stopLabel",
452+ decoded.get("STOP_LABEL")
453+ ),
454+ lineLabel: sharedFavoriteString(
455+ stage,
456+ committed,
457+ "lineLabel",
458+ decoded.get("LINE_LABEL")
459+ ),
460+ destinationLabel: sharedFavoriteString(
461+ stage,
462+ committed,
463+ "destinationLabel",
464+ decoded.get("DESTINATION_LABEL")
465+ ),
466+ sortOrder: decoded.get("SORT_ORDER")
355 };467 };
356- if (decoded.DISPLAY_NAME !== undefined) favorite.displayName = decoded.DISPLAY_NAME;468+ if (decoded.get("DISPLAY_NAME") !== undefined) {
469+ favorite.displayName = sharedFavoriteString(
470+ stage,
471+ committed,
472+ "displayName",
473+ decoded.get("DISPLAY_NAME")
474+ );
475+ }
357 stage.favorites.push(favorite);476 stage.favorites.push(favorite);
358 return RECEIVE_RESULT.STAGED;477 return RECEIVE_RESULT.STAGED;
359 }478 }
@@ -366,12 +485,21 @@ export class ProtocolReceiver {
366 this.configStage = null;485 this.configStage = null;
367 return RECEIVE_RESULT.REJECTED;486 return RECEIVE_RESULT.REJECTED;
368 }487 }
488+ let resultStillConfigured = false;
489+ if (this.result) {
490+ for (let index = 0; index < stage.favorites.length; index += 1) {
491+ if (stage.favorites[index].id === this.result.favoriteId) {
492+ resultStillConfigured = true;
493+ break;
494+ }
495+ }
496+ }
369 this.configuration = {497 this.configuration = {
370 keyStatus: stage.keyStatus,498 keyStatus: stage.keyStatus,
371 language: stage.language,499 language: stage.language,
372- favorites: stage.favorites.map(copyFavorite)500+ favorites: stage.favorites
373 };501 };
374- this.result = null;502+ if (!resultStillConfigured) this.result = null;
375 this.error = null;503 this.error = null;
376 this.configStage = null;504 this.configStage = null;
377 this.cancelExpectedResponse();505 this.cancelExpectedResponse();
@@ -379,37 +507,43 @@ export class ProtocolReceiver {
379 }507 }
380 508
381 if (type === MESSAGE_TYPE.REQUEST) {509 if (type === MESSAGE_TYPE.REQUEST) {
382- const candidate = this.refreshCandidate;510+ const favoriteId = decoded.get("FAVORITE_ID");
383- if (this.expected511+ const sequence = favoriteId === this.favoriteId
384- || !candidate512+ ? requestSequence(this.prefix, requestId)
385- || candidate.requestId !== requestId513+ : 0;
386- || candidate.favoriteId !== decoded.FAVORITE_ID) {514+ if (sequence === 0
515+ || sequence < this.committedSeq
516+ || this.refreshSeq !== -sequence
517+ || !this.result
518+ || this.result.requestId !== requestId
519+ || this.result.favoriteId !== favoriteId) {
387 return RECEIVE_RESULT.REJECTED;520 return RECEIVE_RESULT.REJECTED;
388 }521 }
389- this.expected = {522+ this.refreshSeq = sequence;
390- requestId,
391- favoriteId: decoded.FAVORITE_ID,
392- refresh: true
393- };
394- this.refreshCandidate = null;
395 this.resultStage = null;523 this.resultStage = null;
396 return RECEIVE_RESULT.STAGED;524 return RECEIVE_RESULT.STAGED;
397 }525 }
398 526
399 if (type === MESSAGE_TYPE.RESULT_BEGIN) {527 if (type === MESSAGE_TYPE.RESULT_BEGIN) {
400- if (!this.expected528+ const favoriteId = decoded.get("FAVORITE_ID");
401- || this.expected.requestId !== requestId529+ const sequence = favoriteId === this.favoriteId
402- || this.expected.favoriteId !== decoded.FAVORITE_ID) {530+ ? requestSequence(this.prefix, requestId)
531+ : 0;
532+ const refreshing = sequence !== 0
533+ && sequence === this.refreshSeq
534+ && sequence >= this.committedSeq;
535+ if (!refreshing
536+ && !(sequence > this.committedSeq && sequence <= this.latestSeq)) {
403 this.resultStage = null;537 this.resultStage = null;
404 return RECEIVE_RESULT.REJECTED;538 return RECEIVE_RESULT.REJECTED;
405 }539 }
406 this.resultStage = {540 this.resultStage = {
407 requestId,541 requestId,
408- favoriteId: decoded.FAVORITE_ID,542+ favoriteId,
409- count: decoded.ITEM_COUNT,543+ count: decoded.get("ITEM_COUNT"),
410- fetchedAt: decoded.FETCHED_AT,544+ fetchedAt: decoded.get("FETCHED_AT"),
411- sourceUpdatedAt: decoded.SOURCE_UPDATED_AT,545+ sourceUpdatedAt: decoded.get("SOURCE_UPDATED_AT"),
412- freshness: FRESHNESS[decoded.FRESHNESS],546+ freshness: FRESHNESS[decoded.get("FRESHNESS")],
413 departures: []547 departures: []
414 };548 };
415 return RECEIVE_RESULT.STAGED;549 return RECEIVE_RESULT.STAGED;
@@ -418,24 +552,21 @@ export class ProtocolReceiver {
418 if (type === MESSAGE_TYPE.DEPARTURE) {552 if (type === MESSAGE_TYPE.DEPARTURE) {
419 const stage = this.resultStage;553 const stage = this.resultStage;
420 if (!stage554 if (!stage
421- || !this.expected
422 || stage.requestId !== requestId555 || stage.requestId !== requestId
423- || stage.favoriteId !== decoded.FAVORITE_ID556+ || stage.favoriteId !== decoded.get("FAVORITE_ID")
424- || this.expected.requestId !== requestId557+ || decoded.get("ITEM_INDEX") !== stage.departures.length
425- || this.expected.favoriteId !== decoded.FAVORITE_ID
426- || decoded.ITEM_INDEX !== stage.departures.length
427 || stage.departures.length >= stage.count) {558 || stage.departures.length >= stage.count) {
428 this.resultStage = null;559 this.resultStage = null;
429 return RECEIVE_RESULT.REJECTED;560 return RECEIVE_RESULT.REJECTED;
430 }561 }
431 const departure = {562 const departure = {
432- expectedAt: decoded.EXPECTED_AT,563+ expectedAt: decoded.get("EXPECTED_AT"),
433- minutes: decoded.MINUTES,564+ minutes: decoded.get("MINUTES"),
434- status: DEPARTURE_STATUS[decoded.DEPARTURE_STATUS]565+ status: DEPARTURE_STATUS[decoded.get("DEPARTURE_STATUS")]
435 };566 };
436- if (decoded.AIMED_AT !== undefined) departure.aimedAt = decoded.AIMED_AT;567+ if (decoded.get("AIMED_AT") !== undefined) departure.aimedAt = decoded.get("AIMED_AT");
437- if (decoded.NEXT_INTERVAL_MINUTES !== undefined) {568+ if (decoded.get("NEXT_INTERVAL_MINUTES") !== undefined) {
438- departure.nextIntervalMinutes = decoded.NEXT_INTERVAL_MINUTES;569+ departure.nextIntervalMinutes = decoded.get("NEXT_INTERVAL_MINUTES");
439 }570 }
440 stage.departures.push(departure);571 stage.departures.push(departure);
441 return RECEIVE_RESULT.STAGED;572 return RECEIVE_RESULT.STAGED;
@@ -443,45 +574,55 @@ export class ProtocolReceiver {
443 574
444 if (type === MESSAGE_TYPE.RESULT_COMMIT) {575 if (type === MESSAGE_TYPE.RESULT_COMMIT) {
445 const stage = this.resultStage;576 const stage = this.resultStage;
577+ const favoriteId = decoded.get("FAVORITE_ID");
578+ const sequence = favoriteId === this.favoriteId
579+ ? requestSequence(this.prefix, requestId)
580+ : 0;
581+ const refreshing = sequence !== 0
582+ && sequence === this.refreshSeq
583+ && sequence >= this.committedSeq;
446 if (!stage584 if (!stage
447- || !this.expected585+ || (!refreshing
586+ && !(sequence > this.committedSeq && sequence <= this.latestSeq))
448 || stage.requestId !== requestId587 || stage.requestId !== requestId
449- || stage.favoriteId !== decoded.FAVORITE_ID588+ || stage.favoriteId !== favoriteId
450- || this.expected.requestId !== requestId
451- || this.expected.favoriteId !== decoded.FAVORITE_ID
452 || stage.departures.length !== stage.count) {589 || stage.departures.length !== stage.count) {
453 this.resultStage = null;590 this.resultStage = null;
454 return RECEIVE_RESULT.REJECTED;591 return RECEIVE_RESULT.REJECTED;
455 }592 }
456- const refresh = this.expected.refresh;593+ this.result = stage;
457- this.result = copyResult(stage);
458 this.error = null;594 this.error = null;
459- this.expected = null;595+ if (sequence > this.committedSeq) this.committedSeq = sequence;
460 this.resultStage = null;596 this.resultStage = null;
461- this.refreshCandidate = refresh597+ this.refreshSeq = refreshing ? 0 : -sequence;
462- ? null
463- : { requestId, favoriteId: decoded.FAVORITE_ID };
464 return RECEIVE_RESULT.RESULT_COMMITTED;598 return RECEIVE_RESULT.RESULT_COMMITTED;
465 }599 }
466 600
467 if (type === MESSAGE_TYPE.ERROR) {601 if (type === MESSAGE_TYPE.ERROR) {
468- if (!this.expected602+ const favoriteId = decoded.get("FAVORITE_ID");
469- || this.expected.requestId !== requestId603+ const sequence = (favoriteId === undefined || favoriteId === this.favoriteId)
470- || (decoded.FAVORITE_ID !== undefined604+ ? requestSequence(this.prefix, requestId)
471- && this.expected.favoriteId !== decoded.FAVORITE_ID)) {605+ : 0;
606+ const refreshing = sequence !== 0
607+ && sequence === this.refreshSeq
608+ && sequence >= this.committedSeq;
609+ if (!refreshing
610+ && !(sequence > this.committedSeq && sequence <= this.latestSeq)) {
472 this.resultStage = null;611 this.resultStage = null;
473 return RECEIVE_RESULT.REJECTED;612 return RECEIVE_RESULT.REJECTED;
474 }613 }
475 this.error = {614 this.error = {
476 requestId,615 requestId,
477- code: ERROR_CODE[decoded.ERROR_CODE],616+ code: ERROR_CODE[decoded.get("ERROR_CODE")],
478- occurredAt: decoded.OCCURRED_AT617+ occurredAt: decoded.get("OCCURRED_AT")
479 };618 };
480- if (decoded.FAVORITE_ID !== undefined) this.error.favoriteId = decoded.FAVORITE_ID;619+ if (favoriteId !== undefined) this.error.favoriteId = favoriteId;
481- if (decoded.RETRY_AFTER_SECONDS !== undefined) {620+ if (decoded.get("RETRY_AFTER_SECONDS") !== undefined) {
482- this.error.retryAfterSeconds = decoded.RETRY_AFTER_SECONDS;621+ this.error.retryAfterSeconds = decoded.get("RETRY_AFTER_SECONDS");
483 }622 }
484- this.cancelExpectedResponse();623+ if (sequence > this.committedSeq) this.committedSeq = sequence;
624+ this.refreshSeq = 0;
625+ this.resultStage = null;
485 return RECEIVE_RESULT.ERROR_COMMITTED;626 return RECEIVE_RESULT.ERROR_COMMITTED;
486 }627 }
487 628
modified packages/watch/src/embeddedjs/storage.js +93 -57
@@ -3,7 +3,6 @@ import {
33 LIMITS,
44 SCHEMA_VERSION,
55 boundedString,
6- enumHasValue,
76 isWireLanguage,
87 utf8Bytes
98 } from "./contracts.js";
@@ -13,31 +12,8 @@ export const WATCH_CONFIGURATION_MAX_BYTES = 8192;
1312
1413 const STORAGE_FORMAT = "LFW1";
1514 const STORAGE_SEPARATOR = "\u001f";
16-const CONFIGURATION_KEYS = Object.freeze(["keyStatus", "language", "favorites"]);
17-const FAVORITE_KEYS = Object.freeze([
18- "id",
19- "serviceId",
20- "stopLabel",
21- "lineLabel",
22- "destinationLabel",
23- "sortOrder"
24-]);
25-
26-function exactKeys(value, required, optional = []) {
27- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
28- const keys = Object.keys(value);
29- if (keys.length < required.length || keys.length > required.length + optional.length) {
30- return false;
31- }
32- for (let index = 0; index < required.length; index += 1) {
33- if (!Object.prototype.hasOwnProperty.call(value, required[index])) return false;
34- }
35- for (let index = 0; index < keys.length; index += 1) {
36- if (required.indexOf(keys[index]) === -1 && optional.indexOf(keys[index]) === -1) {
37- return false;
38- }
39- }
40- return true;
15+function owns(value, key) {
16+ return Object.prototype.hasOwnProperty.call(value, key);
4117 }
4218
4319 function validInteger(value) {
@@ -45,8 +21,28 @@ function validInteger(value) {
4521 }
4622
4723 function validFavorite(favorite) {
48- return exactKeys(favorite, FAVORITE_KEYS, ["displayName"])
49- && boundedString(favorite.id, LIMITS.idUtf8Bytes)
24+ if (!favorite || typeof favorite !== "object" || Array.isArray(favorite)) return false;
25+ let keyCount = 0;
26+ for (const key in favorite) {
27+ if (!owns(favorite, key)) continue;
28+ keyCount += 1;
29+ if (key !== "id"
30+ && key !== "serviceId"
31+ && key !== "displayName"
32+ && key !== "stopLabel"
33+ && key !== "lineLabel"
34+ && key !== "destinationLabel"
35+ && key !== "sortOrder") return false;
36+ }
37+ if (keyCount < 6
38+ || keyCount > 7
39+ || !owns(favorite, "id")
40+ || !owns(favorite, "serviceId")
41+ || !owns(favorite, "stopLabel")
42+ || !owns(favorite, "lineLabel")
43+ || !owns(favorite, "destinationLabel")
44+ || !owns(favorite, "sortOrder")) return false;
45+ return boundedString(favorite.id, LIMITS.idUtf8Bytes)
5046 && boundedString(favorite.serviceId, LIMITS.idUtf8Bytes)
5147 && boundedString(favorite.stopLabel, LIMITS.labelUtf8Bytes)
5248 && boundedString(favorite.lineLabel, LIMITS.labelUtf8Bytes)
@@ -60,43 +56,37 @@ function validFavorite(favorite) {
6056
6157 function validFavorites(favorites) {
6258 if (!Array.isArray(favorites) || favorites.length > LIMITS.favorites) return false;
63- const ids = [];
6459 for (let index = 0; index < favorites.length; index += 1) {
6560 const favorite = favorites[index];
66- if (!validFavorite(favorite) || ids.indexOf(favorite.id) !== -1) return false;
67- ids.push(favorite.id);
61+ if (!validFavorite(favorite)) return false;
62+ for (let previous = 0; previous < index; previous += 1) {
63+ if (favorites[previous].id === favorite.id) return false;
64+ }
6865 }
6966 return true;
7067 }
7168
7269 function validConfiguration(configuration) {
73- return exactKeys(configuration, CONFIGURATION_KEYS)
74- && enumHasValue(KEY_STATUS, configuration.keyStatus)
70+ if (!configuration
71+ || typeof configuration !== "object"
72+ || Array.isArray(configuration)) return false;
73+ let keyCount = 0;
74+ for (const key in configuration) {
75+ if (!owns(configuration, key)) continue;
76+ keyCount += 1;
77+ if (key !== "keyStatus" && key !== "language" && key !== "favorites") return false;
78+ }
79+ return keyCount === 3
80+ && owns(configuration, "keyStatus")
81+ && owns(configuration, "language")
82+ && owns(configuration, "favorites")
83+ && (configuration.keyStatus === KEY_STATUS.MISSING
84+ || configuration.keyStatus === KEY_STATUS.CONFIGURED
85+ || configuration.keyStatus === KEY_STATUS.INVALID)
7586 && isWireLanguage(configuration.language)
7687 && validFavorites(configuration.favorites);
7788 }
7889
79-function copyFavorite(favorite) {
80- const copy = {
81- id: favorite.id,
82- serviceId: favorite.serviceId,
83- stopLabel: favorite.stopLabel,
84- lineLabel: favorite.lineLabel,
85- destinationLabel: favorite.destinationLabel,
86- sortOrder: favorite.sortOrder
87- };
88- if (favorite.displayName !== undefined) copy.displayName = favorite.displayName;
89- return copy;
90-}
91-
92-function copyConfiguration(configuration) {
93- return {
94- keyStatus: configuration.keyStatus,
95- language: configuration.language,
96- favorites: configuration.favorites.map(copyFavorite)
97- };
98-}
99-
10090 function appendToken(parts, value) {
10191 parts.push(String(value), STORAGE_SEPARATOR);
10292 }
@@ -110,6 +100,48 @@ function appendString(parts, value) {
110100 parts.push(value);
111101 }
112102
103+function matchesText(state, value) {
104+ if (state.offset + value.length > state.serialized.length) return false;
105+ for (let index = 0; index < value.length; index += 1) {
106+ if (state.serialized.charCodeAt(state.offset + index) !== value.charCodeAt(index)) {
107+ return false;
108+ }
109+ }
110+ state.offset += value.length;
111+ return true;
112+}
113+
114+function matchesToken(state, value) {
115+ return matchesText(state, String(value))
116+ && matchesText(state, STORAGE_SEPARATOR);
117+}
118+
119+function matchesString(state, value) {
120+ return value === undefined
121+ ? matchesToken(state, "-")
122+ : matchesToken(state, value.length) && matchesText(state, value);
123+}
124+
125+function matchesConfiguration(serialized, configuration) {
126+ const state = { serialized, offset: 0 };
127+ if (!matchesToken(state, STORAGE_FORMAT)
128+ || !matchesToken(state, SCHEMA_VERSION)
129+ || !matchesToken(state, configuration.keyStatus)
130+ || !matchesToken(state, configuration.language)
131+ || !matchesToken(state, configuration.favorites.length)) return false;
132+ for (let index = 0; index < configuration.favorites.length; index += 1) {
133+ const favorite = configuration.favorites[index];
134+ if (!matchesToken(state, favorite.sortOrder)
135+ || !matchesString(state, favorite.id)
136+ || !matchesString(state, favorite.serviceId)
137+ || !matchesString(state, favorite.displayName)
138+ || !matchesString(state, favorite.stopLabel)
139+ || !matchesString(state, favorite.lineLabel)
140+ || !matchesString(state, favorite.destinationLabel)) return false;
141+ }
142+ return state.offset === serialized.length;
143+}
144+
113145 export function serializeWatchConfiguration(configuration) {
114146 if (!validConfiguration(configuration)) return null;
115147 const parts = [];
@@ -201,7 +233,7 @@ export function deserializeWatchConfiguration(serialized) {
201233 }
202234 if (!state.valid || state.offset !== serialized.length
203235 || !validConfiguration(configuration)) return null;
204- return copyConfiguration(configuration);
236+ return configuration;
205237 }
206238
207239
@@ -228,14 +260,18 @@ export function saveWatchConfiguration(storage, configuration) {
228260 if (!canWrite(storage)) return false;
229261
230262 let previous = null;
263+ let serialized = null;
231264 let writeAttempted = false;
232265 try {
233- const serialized = serializeWatchConfiguration(configuration);
266+ serialized = serializeWatchConfiguration(configuration);
234267 if (serialized === null) return false;
235268 previous = storage.getItem(WATCH_CONFIGURATION_KEY);
236269 writeAttempted = true;
237270 storage.setItem(WATCH_CONFIGURATION_KEY, serialized);
238- if (storage.getItem(WATCH_CONFIGURATION_KEY) === serialized) return true;
271+ serialized = null;
272+ const written = storage.getItem(WATCH_CONFIGURATION_KEY);
273+ if (typeof written === "string"
274+ && matchesConfiguration(written, configuration)) return true;
239275 } catch (_) {
240276 // Restore the prior bytes below when the adapter is still writable.
241277 }
@@ -3,7 +3,6 @@ import {
3 LIMITS,3 LIMITS,
4 SCHEMA_VERSION,4 SCHEMA_VERSION,
5 boundedString,5 boundedString,
6- enumHasValue,
7 isWireLanguage,6 isWireLanguage,
8 utf8Bytes7 utf8Bytes
9 } from "./contracts.js";8 } from "./contracts.js";
@@ -13,31 +12,8 @@ export const WATCH_CONFIGURATION_MAX_BYTES = 8192;
13 12
14 const STORAGE_FORMAT = "LFW1";13 const STORAGE_FORMAT = "LFW1";
15 const STORAGE_SEPARATOR = "\u001f";14 const STORAGE_SEPARATOR = "\u001f";
16-const CONFIGURATION_KEYS = Object.freeze(["keyStatus", "language", "favorites"]);15+function owns(value, key) {
17-const FAVORITE_KEYS = Object.freeze([16+ return Object.prototype.hasOwnProperty.call(value, key);
18- "id",
19- "serviceId",
20- "stopLabel",
21- "lineLabel",
22- "destinationLabel",
23- "sortOrder"
24-]);
25-
26-function exactKeys(value, required, optional = []) {
27- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
28- const keys = Object.keys(value);
29- if (keys.length < required.length || keys.length > required.length + optional.length) {
30- return false;
31- }
32- for (let index = 0; index < required.length; index += 1) {
33- if (!Object.prototype.hasOwnProperty.call(value, required[index])) return false;
34- }
35- for (let index = 0; index < keys.length; index += 1) {
36- if (required.indexOf(keys[index]) === -1 && optional.indexOf(keys[index]) === -1) {
37- return false;
38- }
39- }
40- return true;
41 }17 }
42 18
43 function validInteger(value) {19 function validInteger(value) {
@@ -45,8 +21,28 @@ function validInteger(value) {
45 }21 }
46 22
47 function validFavorite(favorite) {23 function validFavorite(favorite) {
48- return exactKeys(favorite, FAVORITE_KEYS, ["displayName"])24+ if (!favorite || typeof favorite !== "object" || Array.isArray(favorite)) return false;
49- && boundedString(favorite.id, LIMITS.idUtf8Bytes)25+ let keyCount = 0;
26+ for (const key in favorite) {
27+ if (!owns(favorite, key)) continue;
28+ keyCount += 1;
29+ if (key !== "id"
30+ && key !== "serviceId"
31+ && key !== "displayName"
32+ && key !== "stopLabel"
33+ && key !== "lineLabel"
34+ && key !== "destinationLabel"
35+ && key !== "sortOrder") return false;
36+ }
37+ if (keyCount < 6
38+ || keyCount > 7
39+ || !owns(favorite, "id")
40+ || !owns(favorite, "serviceId")
41+ || !owns(favorite, "stopLabel")
42+ || !owns(favorite, "lineLabel")
43+ || !owns(favorite, "destinationLabel")
44+ || !owns(favorite, "sortOrder")) return false;
45+ return boundedString(favorite.id, LIMITS.idUtf8Bytes)
50 && boundedString(favorite.serviceId, LIMITS.idUtf8Bytes)46 && boundedString(favorite.serviceId, LIMITS.idUtf8Bytes)
51 && boundedString(favorite.stopLabel, LIMITS.labelUtf8Bytes)47 && boundedString(favorite.stopLabel, LIMITS.labelUtf8Bytes)
52 && boundedString(favorite.lineLabel, LIMITS.labelUtf8Bytes)48 && boundedString(favorite.lineLabel, LIMITS.labelUtf8Bytes)
@@ -60,43 +56,37 @@ function validFavorite(favorite) {
60 56
61 function validFavorites(favorites) {57 function validFavorites(favorites) {
62 if (!Array.isArray(favorites) || favorites.length > LIMITS.favorites) return false;58 if (!Array.isArray(favorites) || favorites.length > LIMITS.favorites) return false;
63- const ids = [];
64 for (let index = 0; index < favorites.length; index += 1) {59 for (let index = 0; index < favorites.length; index += 1) {
65 const favorite = favorites[index];60 const favorite = favorites[index];
66- if (!validFavorite(favorite) || ids.indexOf(favorite.id) !== -1) return false;61+ if (!validFavorite(favorite)) return false;
67- ids.push(favorite.id);62+ for (let previous = 0; previous < index; previous += 1) {
63+ if (favorites[previous].id === favorite.id) return false;
64+ }
68 }65 }
69 return true;66 return true;
70 }67 }
71 68
72 function validConfiguration(configuration) {69 function validConfiguration(configuration) {
73- return exactKeys(configuration, CONFIGURATION_KEYS)70+ if (!configuration
74- && enumHasValue(KEY_STATUS, configuration.keyStatus)71+ || typeof configuration !== "object"
72+ || Array.isArray(configuration)) return false;
73+ let keyCount = 0;
74+ for (const key in configuration) {
75+ if (!owns(configuration, key)) continue;
76+ keyCount += 1;
77+ if (key !== "keyStatus" && key !== "language" && key !== "favorites") return false;
78+ }
79+ return keyCount === 3
80+ && owns(configuration, "keyStatus")
81+ && owns(configuration, "language")
82+ && owns(configuration, "favorites")
83+ && (configuration.keyStatus === KEY_STATUS.MISSING
84+ || configuration.keyStatus === KEY_STATUS.CONFIGURED
85+ || configuration.keyStatus === KEY_STATUS.INVALID)
75 && isWireLanguage(configuration.language)86 && isWireLanguage(configuration.language)
76 && validFavorites(configuration.favorites);87 && validFavorites(configuration.favorites);
77 }88 }
78 89
79-function copyFavorite(favorite) {
80- const copy = {
81- id: favorite.id,
82- serviceId: favorite.serviceId,
83- stopLabel: favorite.stopLabel,
84- lineLabel: favorite.lineLabel,
85- destinationLabel: favorite.destinationLabel,
86- sortOrder: favorite.sortOrder
87- };
88- if (favorite.displayName !== undefined) copy.displayName = favorite.displayName;
89- return copy;
90-}
91-
92-function copyConfiguration(configuration) {
93- return {
94- keyStatus: configuration.keyStatus,
95- language: configuration.language,
96- favorites: configuration.favorites.map(copyFavorite)
97- };
98-}
99-
100 function appendToken(parts, value) {90 function appendToken(parts, value) {
101 parts.push(String(value), STORAGE_SEPARATOR);91 parts.push(String(value), STORAGE_SEPARATOR);
102 }92 }
@@ -110,6 +100,48 @@ function appendString(parts, value) {
110 parts.push(value);100 parts.push(value);
111 }101 }
112 102
103+function matchesText(state, value) {
104+ if (state.offset + value.length > state.serialized.length) return false;
105+ for (let index = 0; index < value.length; index += 1) {
106+ if (state.serialized.charCodeAt(state.offset + index) !== value.charCodeAt(index)) {
107+ return false;
108+ }
109+ }
110+ state.offset += value.length;
111+ return true;
112+}
113+
114+function matchesToken(state, value) {
115+ return matchesText(state, String(value))
116+ && matchesText(state, STORAGE_SEPARATOR);
117+}
118+
119+function matchesString(state, value) {
120+ return value === undefined
121+ ? matchesToken(state, "-")
122+ : matchesToken(state, value.length) && matchesText(state, value);
123+}
124+
125+function matchesConfiguration(serialized, configuration) {
126+ const state = { serialized, offset: 0 };
127+ if (!matchesToken(state, STORAGE_FORMAT)
128+ || !matchesToken(state, SCHEMA_VERSION)
129+ || !matchesToken(state, configuration.keyStatus)
130+ || !matchesToken(state, configuration.language)
131+ || !matchesToken(state, configuration.favorites.length)) return false;
132+ for (let index = 0; index < configuration.favorites.length; index += 1) {
133+ const favorite = configuration.favorites[index];
134+ if (!matchesToken(state, favorite.sortOrder)
135+ || !matchesString(state, favorite.id)
136+ || !matchesString(state, favorite.serviceId)
137+ || !matchesString(state, favorite.displayName)
138+ || !matchesString(state, favorite.stopLabel)
139+ || !matchesString(state, favorite.lineLabel)
140+ || !matchesString(state, favorite.destinationLabel)) return false;
141+ }
142+ return state.offset === serialized.length;
143+}
144+
113 export function serializeWatchConfiguration(configuration) {145 export function serializeWatchConfiguration(configuration) {
114 if (!validConfiguration(configuration)) return null;146 if (!validConfiguration(configuration)) return null;
115 const parts = [];147 const parts = [];
@@ -201,7 +233,7 @@ export function deserializeWatchConfiguration(serialized) {
201 }233 }
202 if (!state.valid || state.offset !== serialized.length234 if (!state.valid || state.offset !== serialized.length
203 || !validConfiguration(configuration)) return null;235 || !validConfiguration(configuration)) return null;
204- return copyConfiguration(configuration);236+ return configuration;
205 }237 }
206 238
207 239
@@ -228,14 +260,18 @@ export function saveWatchConfiguration(storage, configuration) {
228 if (!canWrite(storage)) return false;260 if (!canWrite(storage)) return false;
229 261
230 let previous = null;262 let previous = null;
263+ let serialized = null;
231 let writeAttempted = false;264 let writeAttempted = false;
232 try {265 try {
233- const serialized = serializeWatchConfiguration(configuration);266+ serialized = serializeWatchConfiguration(configuration);
234 if (serialized === null) return false;267 if (serialized === null) return false;
235 previous = storage.getItem(WATCH_CONFIGURATION_KEY);268 previous = storage.getItem(WATCH_CONFIGURATION_KEY);
236 writeAttempted = true;269 writeAttempted = true;
237 storage.setItem(WATCH_CONFIGURATION_KEY, serialized);270 storage.setItem(WATCH_CONFIGURATION_KEY, serialized);
238- if (storage.getItem(WATCH_CONFIGURATION_KEY) === serialized) return true;271+ serialized = null;
272+ const written = storage.getItem(WATCH_CONFIGURATION_KEY);
273+ if (typeof written === "string"
274+ && matchesConfiguration(written, configuration)) return true;
239 } catch (_) {275 } catch (_) {
240 // Restore the prior bytes below when the adapter is still writable.276 // Restore the prior bytes below when the adapter is still writable.
241 }277 }
modified packages/watch/src/embeddedjs/ui.js +294 -88
@@ -1,131 +1,337 @@
11 import {} from "piu/MC";
2-import { copy, formatMinutes } from "./localization.js";
3-
4-const BACKGROUND_SKIN = new Skin({ fill: "white" });
5-const HEADER_STYLE = new Style({
6- font: "bold 18px Gothic",
7- color: "black",
8- horizontal: "center",
9- leading: 1
10-});
11-const BODY_STYLE = new Style({
12- font: "18px Gothic",
13- color: "black",
14- horizontal: "center",
15- leading: 2
16-});
17-const FOOTER_STYLE = new Style({
18- font: "14px Gothic",
19- color: "black",
20- horizontal: "center"
21-});
2+import { createPresentation } from "./presentation.js";
3+
4+let contentStyle = null;
5+let footerStyle = null;
6+
7+function contentStyleForDraw() {
8+ if (!contentStyle) {
9+ contentStyle = new Style({
10+ font: "bold 14px Gothic",
11+ color: "black",
12+ horizontal: "center",
13+ vertical: "middle",
14+ leading: 0
15+ });
16+ }
17+ return contentStyle;
18+}
19+
20+function footerStyleForDraw() {
21+ if (!footerStyle) {
22+ footerStyle = new Style({
23+ font: "14px Gothic",
24+ color: "black",
25+ horizontal: "center",
26+ leading: 0
27+ });
28+ }
29+ return footerStyle;
30+}
31+const CONTENT_LINE_HEIGHT = 16;
32+const FOOTER_HEIGHT = 30;
33+const FOOTER_LINE_HEIGHT = 15;
2234
2335 function geometry(screenInfo) {
2436 const width = Math.max(1, Number(screenInfo.width) || 1);
2537 const height = Math.max(1, Number(screenInfo.height) || 1);
2638 const round = screenInfo.round === true;
27- const horizontal = round
39+ const insetX = round
2840 ? Math.max(26, Math.floor(width * 0.13))
2941 : Math.max(8, Math.floor(width * 0.04));
30- const vertical = round
42+ const insetY = round
3143 ? Math.max(18, Math.floor(height * 0.08))
3244 : Math.max(6, Math.floor(height * 0.025));
33- const headerHeight = Math.floor(height * (round ? 0.27 : 0.25));
34- const footerHeight = Math.floor(height * 0.12);
45+ const safeHeight = Math.max(1, height - insetY * 2);
46+ const favoriteHeight = round ? 18 : 20;
47+ const headerHeight = round ? 32 : 42;
48+ const bodyTop = favoriteHeight + headerHeight;
3549 return {
36- rows: round ? 2 : 3,
37- header: {
38- left: horizontal,
39- right: horizontal,
40- top: vertical,
41- height: headerHeight
42- },
43- body: {
44- left: horizontal,
45- right: horizontal,
46- top: vertical + headerHeight,
47- bottom: vertical + footerHeight
48- },
49- footer: {
50- left: horizontal,
51- right: horizontal,
52- bottom: vertical,
53- height: footerHeight
54- }
50+ insetX,
51+ insetY,
52+ safeWidth: Math.max(1, width - insetX * 2),
53+ safeHeight,
54+ favoriteHeight,
55+ headerHeight,
56+ bodyTop,
57+ bodyHeight: Math.max(1, safeHeight - bodyTop - FOOTER_HEIGHT),
58+ round,
59+ hour12: screenInfo.hour12 === true
5560 };
5661 }
5762
58-function setString(content, value) {
59- if (content.string !== value) content.string = value;
63+function rowEdge(layout, index) {
64+ const rowLimit = layout.round ? 2 : 3;
65+ return layout.bodyTop
66+ + Math.floor(layout.bodyHeight * Math.min(index, rowLimit) / rowLimit);
6067 }
6168
62-function headerText(snapshot) {
63- if (!snapshot.favorite) return copy(snapshot.language, "appName");
64- return snapshot.favorite.lineLabel + " → " + snapshot.favorite.destinationLabel;
69+function fittedEnd(port, string, start, end, style, width) {
70+ let low = start + 1;
71+ let high = end;
72+ let fitted = low;
73+ while (low <= high) {
74+ const middle = low + Math.floor((high - low) / 2);
75+ if (port.measureString(string.slice(start, middle), style).width <= width) {
76+ fitted = middle;
77+ low = middle + 1;
78+ } else {
79+ high = middle - 1;
80+ }
81+ }
82+ if (fitted >= end) return end;
83+ for (let index = fitted; index > start; index -= 1) {
84+ const code = string.charCodeAt(index - 1);
85+ if (code === 32 || code === 9) return index - 1;
86+ }
87+ return fitted;
6588 }
6689
67-function bodyText(snapshot, rowLimit) {
68- if (snapshot.result) {
69- if (snapshot.result.departures.length === 0) {
70- return copy(snapshot.language, "noDepartures");
90+function ellipsizedLine(port, string, style, width) {
91+ if (port.measureString(string, style).width <= width) return string;
92+ const ellipsis = "…";
93+ const ellipsisWidth = port.measureString(ellipsis, style).width;
94+ if (ellipsisWidth > width) return "";
95+ let low = 0;
96+ let high = string.length;
97+ let fitted = 0;
98+ while (low <= high) {
99+ const middle = low + Math.floor((high - low) / 2);
100+ const candidate = string.slice(0, middle) + ellipsis;
101+ if (port.measureString(candidate, style).width <= width) {
102+ fitted = middle;
103+ low = middle + 1;
104+ } else {
105+ high = middle - 1;
71106 }
72- const lines = [];
73- const count = Math.min(rowLimit, snapshot.result.departures.length);
74- for (let index = 0; index < count; index += 1) {
75- lines.push(formatMinutes(snapshot.language, snapshot.result.departures[index].minutes));
107+ }
108+ return string.slice(0, fitted) + ellipsis;
109+}
110+
111+function drawRouteHeader(port, header, style, x, y, width, height) {
112+ const separator = header.indexOf("\n");
113+ const lineLabel = separator < 0 ? header : header.slice(0, separator);
114+ const destinationLabel = separator < 0 ? "" : header.slice(separator + 1);
115+ const top = y + Math.max(0, Math.floor(
116+ (height - CONTENT_LINE_HEIGHT * 2) / 2
117+ ));
118+ const labels = [lineLabel, destinationLabel];
119+ for (let index = 0; index < labels.length; index += 1) {
120+ if (labels[index] === "") continue;
121+ port.drawStyle(
122+ ellipsizedLine(port, labels[index], style, width),
123+ style,
124+ x,
125+ top + index * CONTENT_LINE_HEIGHT,
126+ width,
127+ CONTENT_LINE_HEIGHT,
128+ true,
129+ 0
130+ );
131+ }
132+}
133+
134+function wrappedLines(port, string, style, width, maximum) {
135+ const lines = [];
136+ let start = 0;
137+ while (start <= string.length && lines.length < maximum) {
138+ let hardEnd = string.indexOf("\n", start);
139+ if (hardEnd < 0) hardEnd = string.length;
140+ if (lines.length === maximum - 1) {
141+ lines.push(string.slice(start, hardEnd));
142+ break;
143+ }
144+ if (start === hardEnd) {
145+ lines.push("");
146+ if (hardEnd === string.length) break;
147+ start = hardEnd + 1;
148+ continue;
149+ }
150+ const end = port.measureString(string.slice(start, hardEnd), style).width <= width
151+ ? hardEnd
152+ : fittedEnd(port, string, start, hardEnd, style, width);
153+ lines.push(string.slice(start, end));
154+ start = end;
155+ while (start < hardEnd
156+ && (string.charCodeAt(start) === 32 || string.charCodeAt(start) === 9)) {
157+ start += 1;
158+ }
159+ if (start >= hardEnd) {
160+ if (hardEnd === string.length) break;
161+ start = hardEnd + 1;
76162 }
77- return lines.join("\n");
78163 }
79- if (snapshot.error || snapshot.sendFailed) return copy(snapshot.language, "unavailable");
80- return snapshot.favorite
81- ? copy(snapshot.language, "waiting")
82- : copy(snapshot.language, "synchronizing");
164+ return lines;
83165 }
84166
85-function footerText(snapshot) {
86- if (snapshot.result) return copy(snapshot.language, "recordedFixture");
87- if (!snapshot.favorite) return "";
88- return snapshot.favorite.displayName || snapshot.favorite.stopLabel;
167+function drawWrapped(
168+ port,
169+ string,
170+ style,
171+ x,
172+ y,
173+ width,
174+ height,
175+ lineHeight = CONTENT_LINE_HEIGHT
176+) {
177+ if (string === "") return;
178+ const maximum = Math.max(1, Math.floor(height / lineHeight));
179+ const lines = wrappedLines(port, string, style, width, maximum);
180+ let top = y + Math.max(0, Math.floor((height - lines.length * lineHeight) / 2));
181+ for (let index = 0; index < lines.length; index += 1) {
182+ port.drawStyle(lines[index], style, x, top, width, lineHeight, true, 0);
183+ top += lineHeight;
184+ }
185+}
186+
187+function sameSnapshot(left, right) {
188+ return left
189+ && right
190+ && left.state === right.state
191+ && left.language === right.language
192+ && left.nowMs === right.nowMs
193+ && left.activeFavorite === right.activeFavorite
194+ && left.result === right.result
195+ && left.error === right.error
196+ && left.sendFailed === right.sendFailed;
197+}
198+
199+class WatchPortBehavior extends Behavior {
200+ onCreate() {
201+ this.layout = null;
202+ this.snapshot = null;
203+ }
204+
205+ onDraw(port) {
206+ const layout = this.layout;
207+ const snapshot = this.snapshot;
208+ port.fillColor("white", 0, 0, port.width, port.height);
209+ if (!layout || !snapshot) return;
210+ const presentation = createPresentation(snapshot, layout);
211+ const primaryStyle = contentStyleForDraw();
212+ const x = layout.insetX;
213+ const y = layout.insetY;
214+ const width = layout.safeWidth;
215+ const critical = presentation.stateMessage !== "" && presentation.footer === "";
216+ port.pushClip(x, y, width, layout.safeHeight);
217+
218+ if (presentation.favoriteLabel !== "") {
219+ port.drawStyle(
220+ presentation.favoriteLabel,
221+ primaryStyle,
222+ x,
223+ y,
224+ width,
225+ layout.favoriteHeight,
226+ true,
227+ 0
228+ );
229+ }
230+
231+ if (critical) {
232+ const minimumTop = presentation.favoriteLabel !== "" ? layout.favoriteHeight : 0;
233+ const criticalHeight = layout.round ? 84 : 48;
234+ const top = minimumTop
235+ + Math.floor((layout.safeHeight - minimumTop - criticalHeight) / 2);
236+ drawWrapped(
237+ port,
238+ presentation.stateMessage,
239+ primaryStyle,
240+ x,
241+ y + top,
242+ width,
243+ criticalHeight
244+ );
245+ } else {
246+ drawRouteHeader(
247+ port,
248+ presentation.header,
249+ primaryStyle,
250+ x,
251+ y + layout.favoriteHeight,
252+ width,
253+ layout.headerHeight
254+ );
255+ if (presentation.stateMessage !== "") {
256+ port.drawStyle(
257+ presentation.stateMessage,
258+ primaryStyle,
259+ x,
260+ y + layout.bodyTop,
261+ width,
262+ layout.bodyHeight,
263+ true,
264+ 0
265+ );
266+ } else {
267+ for (let index = 0; index < presentation.rows.length; index += 1) {
268+ const top = rowEdge(layout, index);
269+ port.drawStyle(
270+ presentation.rows[index],
271+ primaryStyle,
272+ x,
273+ y + top,
274+ width,
275+ rowEdge(layout, index + 1) - top,
276+ true,
277+ 0
278+ );
279+ }
280+ }
281+ if (presentation.footer !== "") {
282+ drawWrapped(
283+ port,
284+ presentation.footer,
285+ footerStyleForDraw(),
286+ x,
287+ y + layout.safeHeight - FOOTER_HEIGHT,
288+ width,
289+ FOOTER_HEIGHT,
290+ FOOTER_LINE_HEIGHT
291+ );
292+ }
293+ }
294+ port.popClip();
295+ }
89296 }
90297
91298 export function createWatchView(screenInfo) {
92299 let layout = geometry(screenInfo);
93- const header = new Text(null, {
94- ...layout.header,
95- style: HEADER_STYLE,
96- string: ""
97- });
98- const body = new Text(null, {
99- ...layout.body,
100- style: BODY_STYLE,
101- string: ""
102- });
103- const footer = new Text(null, {
104- ...layout.footer,
105- style: FOOTER_STYLE,
106- string: ""
300+ const port = new Port(null, {
301+ left: 0,
302+ right: 0,
303+ top: 0,
304+ bottom: 0,
305+ Behavior: WatchPortBehavior
107306 });
307+ port.behavior.layout = layout;
108308 const application = new Application(null, {
109- skin: BACKGROUND_SKIN,
309+ clip: true,
110310 touchCount: 0,
111- displayListLength: 2048,
112- contents: [header, body, footer]
311+ contents: [port]
113312 });
114313
115314 return {
116315 application,
117316 render(snapshot) {
118- setString(header, headerText(snapshot));
119- setString(body, bodyText(snapshot, layout.rows));
120- setString(footer, footerText(snapshot));
317+ if (sameSnapshot(port.behavior.snapshot, snapshot)) return;
318+ port.behavior.snapshot = snapshot;
319+ port.invalidate();
121320 },
122321 resize(nextScreenInfo) {
123322 layout = geometry(nextScreenInfo);
124- header.coordinates = layout.header;
125- body.coordinates = layout.body;
126- footer.coordinates = layout.footer;
323+ port.behavior.layout = layout;
324+ port.invalidate();
325+ },
326+ releasePresentation() {
327+ port.behavior.snapshot = null;
328+ contentStyle = null;
329+ footerStyle = null;
127330 },
128331 close() {
332+ port.behavior.snapshot = null;
333+ contentStyle = null;
334+ footerStyle = null;
129335 application.empty();
130336 }
131337 };
@@ -1,131 +1,337 @@
1 import {} from "piu/MC";1 import {} from "piu/MC";
2-import { copy, formatMinutes } from "./localization.js";2+import { createPresentation } from "./presentation.js";
3-3+
4-const BACKGROUND_SKIN = new Skin({ fill: "white" });4+let contentStyle = null;
5-const HEADER_STYLE = new Style({5+let footerStyle = null;
6- font: "bold 18px Gothic",6+
7- color: "black",7+function contentStyleForDraw() {
8- horizontal: "center",8+ if (!contentStyle) {
9- leading: 19+ contentStyle = new Style({
10-});10+ font: "bold 14px Gothic",
11-const BODY_STYLE = new Style({11+ color: "black",
12- font: "18px Gothic",12+ horizontal: "center",
13- color: "black",13+ vertical: "middle",
14- horizontal: "center",14+ leading: 0
15- leading: 215+ });
16-});16+ }
17-const FOOTER_STYLE = new Style({17+ return contentStyle;
18- font: "14px Gothic",18+}
19- color: "black",19+
20- horizontal: "center"20+function footerStyleForDraw() {
21-});21+ if (!footerStyle) {
22+ footerStyle = new Style({
23+ font: "14px Gothic",
24+ color: "black",
25+ horizontal: "center",
26+ leading: 0
27+ });
28+ }
29+ return footerStyle;
30+}
31+const CONTENT_LINE_HEIGHT = 16;
32+const FOOTER_HEIGHT = 30;
33+const FOOTER_LINE_HEIGHT = 15;
22 34
23 function geometry(screenInfo) {35 function geometry(screenInfo) {
24 const width = Math.max(1, Number(screenInfo.width) || 1);36 const width = Math.max(1, Number(screenInfo.width) || 1);
25 const height = Math.max(1, Number(screenInfo.height) || 1);37 const height = Math.max(1, Number(screenInfo.height) || 1);
26 const round = screenInfo.round === true;38 const round = screenInfo.round === true;
27- const horizontal = round39+ const insetX = round
28 ? Math.max(26, Math.floor(width * 0.13))40 ? Math.max(26, Math.floor(width * 0.13))
29 : Math.max(8, Math.floor(width * 0.04));41 : Math.max(8, Math.floor(width * 0.04));
30- const vertical = round42+ const insetY = round
31 ? Math.max(18, Math.floor(height * 0.08))43 ? Math.max(18, Math.floor(height * 0.08))
32 : Math.max(6, Math.floor(height * 0.025));44 : Math.max(6, Math.floor(height * 0.025));
33- const headerHeight = Math.floor(height * (round ? 0.27 : 0.25));45+ const safeHeight = Math.max(1, height - insetY * 2);
34- const footerHeight = Math.floor(height * 0.12);46+ const favoriteHeight = round ? 18 : 20;
47+ const headerHeight = round ? 32 : 42;
48+ const bodyTop = favoriteHeight + headerHeight;
35 return {49 return {
36- rows: round ? 2 : 3,50+ insetX,
37- header: {51+ insetY,
38- left: horizontal,52+ safeWidth: Math.max(1, width - insetX * 2),
39- right: horizontal,53+ safeHeight,
40- top: vertical,54+ favoriteHeight,
41- height: headerHeight55+ headerHeight,
42- },56+ bodyTop,
43- body: {57+ bodyHeight: Math.max(1, safeHeight - bodyTop - FOOTER_HEIGHT),
44- left: horizontal,58+ round,
45- right: horizontal,59+ hour12: screenInfo.hour12 === true
46- top: vertical + headerHeight,
47- bottom: vertical + footerHeight
48- },
49- footer: {
50- left: horizontal,
51- right: horizontal,
52- bottom: vertical,
53- height: footerHeight
54- }
55 };60 };
56 }61 }
57 62
58-function setString(content, value) {63+function rowEdge(layout, index) {
59- if (content.string !== value) content.string = value;64+ const rowLimit = layout.round ? 2 : 3;
65+ return layout.bodyTop
66+ + Math.floor(layout.bodyHeight * Math.min(index, rowLimit) / rowLimit);
60 }67 }
61 68
62-function headerText(snapshot) {69+function fittedEnd(port, string, start, end, style, width) {
63- if (!snapshot.favorite) return copy(snapshot.language, "appName");70+ let low = start + 1;
64- return snapshot.favorite.lineLabel + " → " + snapshot.favorite.destinationLabel;71+ let high = end;
72+ let fitted = low;
73+ while (low <= high) {
74+ const middle = low + Math.floor((high - low) / 2);
75+ if (port.measureString(string.slice(start, middle), style).width <= width) {
76+ fitted = middle;
77+ low = middle + 1;
78+ } else {
79+ high = middle - 1;
80+ }
81+ }
82+ if (fitted >= end) return end;
83+ for (let index = fitted; index > start; index -= 1) {
84+ const code = string.charCodeAt(index - 1);
85+ if (code === 32 || code === 9) return index - 1;
86+ }
87+ return fitted;
65 }88 }
66 89
67-function bodyText(snapshot, rowLimit) {90+function ellipsizedLine(port, string, style, width) {
68- if (snapshot.result) {91+ if (port.measureString(string, style).width <= width) return string;
69- if (snapshot.result.departures.length === 0) {92+ const ellipsis = "…";
70- return copy(snapshot.language, "noDepartures");93+ const ellipsisWidth = port.measureString(ellipsis, style).width;
94+ if (ellipsisWidth > width) return "";
95+ let low = 0;
96+ let high = string.length;
97+ let fitted = 0;
98+ while (low <= high) {
99+ const middle = low + Math.floor((high - low) / 2);
100+ const candidate = string.slice(0, middle) + ellipsis;
101+ if (port.measureString(candidate, style).width <= width) {
102+ fitted = middle;
103+ low = middle + 1;
104+ } else {
105+ high = middle - 1;
71 }106 }
72- const lines = [];107+ }
73- const count = Math.min(rowLimit, snapshot.result.departures.length);108+ return string.slice(0, fitted) + ellipsis;
74- for (let index = 0; index < count; index += 1) {109+}
75- lines.push(formatMinutes(snapshot.language, snapshot.result.departures[index].minutes));110+
111+function drawRouteHeader(port, header, style, x, y, width, height) {
112+ const separator = header.indexOf("\n");
113+ const lineLabel = separator < 0 ? header : header.slice(0, separator);
114+ const destinationLabel = separator < 0 ? "" : header.slice(separator + 1);
115+ const top = y + Math.max(0, Math.floor(
116+ (height - CONTENT_LINE_HEIGHT * 2) / 2
117+ ));
118+ const labels = [lineLabel, destinationLabel];
119+ for (let index = 0; index < labels.length; index += 1) {
120+ if (labels[index] === "") continue;
121+ port.drawStyle(
122+ ellipsizedLine(port, labels[index], style, width),
123+ style,
124+ x,
125+ top + index * CONTENT_LINE_HEIGHT,
126+ width,
127+ CONTENT_LINE_HEIGHT,
128+ true,
129+ 0
130+ );
131+ }
132+}
133+
134+function wrappedLines(port, string, style, width, maximum) {
135+ const lines = [];
136+ let start = 0;
137+ while (start <= string.length && lines.length < maximum) {
138+ let hardEnd = string.indexOf("\n", start);
139+ if (hardEnd < 0) hardEnd = string.length;
140+ if (lines.length === maximum - 1) {
141+ lines.push(string.slice(start, hardEnd));
142+ break;
143+ }
144+ if (start === hardEnd) {
145+ lines.push("");
146+ if (hardEnd === string.length) break;
147+ start = hardEnd + 1;
148+ continue;
149+ }
150+ const end = port.measureString(string.slice(start, hardEnd), style).width <= width
151+ ? hardEnd
152+ : fittedEnd(port, string, start, hardEnd, style, width);
153+ lines.push(string.slice(start, end));
154+ start = end;
155+ while (start < hardEnd
156+ && (string.charCodeAt(start) === 32 || string.charCodeAt(start) === 9)) {
157+ start += 1;
158+ }
159+ if (start >= hardEnd) {
160+ if (hardEnd === string.length) break;
161+ start = hardEnd + 1;
76 }162 }
77- return lines.join("\n");
78 }163 }
79- if (snapshot.error || snapshot.sendFailed) return copy(snapshot.language, "unavailable");164+ return lines;
80- return snapshot.favorite
81- ? copy(snapshot.language, "waiting")
82- : copy(snapshot.language, "synchronizing");
83 }165 }
84 166
85-function footerText(snapshot) {167+function drawWrapped(
86- if (snapshot.result) return copy(snapshot.language, "recordedFixture");168+ port,
87- if (!snapshot.favorite) return "";169+ string,
88- return snapshot.favorite.displayName || snapshot.favorite.stopLabel;170+ style,
171+ x,
172+ y,
173+ width,
174+ height,
175+ lineHeight = CONTENT_LINE_HEIGHT
176+) {
177+ if (string === "") return;
178+ const maximum = Math.max(1, Math.floor(height / lineHeight));
179+ const lines = wrappedLines(port, string, style, width, maximum);
180+ let top = y + Math.max(0, Math.floor((height - lines.length * lineHeight) / 2));
181+ for (let index = 0; index < lines.length; index += 1) {
182+ port.drawStyle(lines[index], style, x, top, width, lineHeight, true, 0);
183+ top += lineHeight;
184+ }
185+}
186+
187+function sameSnapshot(left, right) {
188+ return left
189+ && right
190+ && left.state === right.state
191+ && left.language === right.language
192+ && left.nowMs === right.nowMs
193+ && left.activeFavorite === right.activeFavorite
194+ && left.result === right.result
195+ && left.error === right.error
196+ && left.sendFailed === right.sendFailed;
197+}
198+
199+class WatchPortBehavior extends Behavior {
200+ onCreate() {
201+ this.layout = null;
202+ this.snapshot = null;
203+ }
204+
205+ onDraw(port) {
206+ const layout = this.layout;
207+ const snapshot = this.snapshot;
208+ port.fillColor("white", 0, 0, port.width, port.height);
209+ if (!layout || !snapshot) return;
210+ const presentation = createPresentation(snapshot, layout);
211+ const primaryStyle = contentStyleForDraw();
212+ const x = layout.insetX;
213+ const y = layout.insetY;
214+ const width = layout.safeWidth;
215+ const critical = presentation.stateMessage !== "" && presentation.footer === "";
216+ port.pushClip(x, y, width, layout.safeHeight);
217+
218+ if (presentation.favoriteLabel !== "") {
219+ port.drawStyle(
220+ presentation.favoriteLabel,
221+ primaryStyle,
222+ x,
223+ y,
224+ width,
225+ layout.favoriteHeight,
226+ true,
227+ 0
228+ );
229+ }
230+
231+ if (critical) {
232+ const minimumTop = presentation.favoriteLabel !== "" ? layout.favoriteHeight : 0;
233+ const criticalHeight = layout.round ? 84 : 48;
234+ const top = minimumTop
235+ + Math.floor((layout.safeHeight - minimumTop - criticalHeight) / 2);
236+ drawWrapped(
237+ port,
238+ presentation.stateMessage,
239+ primaryStyle,
240+ x,
241+ y + top,
242+ width,
243+ criticalHeight
244+ );
245+ } else {
246+ drawRouteHeader(
247+ port,
248+ presentation.header,
249+ primaryStyle,
250+ x,
251+ y + layout.favoriteHeight,
252+ width,
253+ layout.headerHeight
254+ );
255+ if (presentation.stateMessage !== "") {
256+ port.drawStyle(
257+ presentation.stateMessage,
258+ primaryStyle,
259+ x,
260+ y + layout.bodyTop,
261+ width,
262+ layout.bodyHeight,
263+ true,
264+ 0
265+ );
266+ } else {
267+ for (let index = 0; index < presentation.rows.length; index += 1) {
268+ const top = rowEdge(layout, index);
269+ port.drawStyle(
270+ presentation.rows[index],
271+ primaryStyle,
272+ x,
273+ y + top,
274+ width,
275+ rowEdge(layout, index + 1) - top,
276+ true,
277+ 0
278+ );
279+ }
280+ }
281+ if (presentation.footer !== "") {
282+ drawWrapped(
283+ port,
284+ presentation.footer,
285+ footerStyleForDraw(),
286+ x,
287+ y + layout.safeHeight - FOOTER_HEIGHT,
288+ width,
289+ FOOTER_HEIGHT,
290+ FOOTER_LINE_HEIGHT
291+ );
292+ }
293+ }
294+ port.popClip();
295+ }
89 }296 }
90 297
91 export function createWatchView(screenInfo) {298 export function createWatchView(screenInfo) {
92 let layout = geometry(screenInfo);299 let layout = geometry(screenInfo);
93- const header = new Text(null, {300+ const port = new Port(null, {
94- ...layout.header,301+ left: 0,
95- style: HEADER_STYLE,302+ right: 0,
96- string: ""303+ top: 0,
97- });304+ bottom: 0,
98- const body = new Text(null, {305+ Behavior: WatchPortBehavior
99- ...layout.body,
100- style: BODY_STYLE,
101- string: ""
102- });
103- const footer = new Text(null, {
104- ...layout.footer,
105- style: FOOTER_STYLE,
106- string: ""
107 });306 });
307+ port.behavior.layout = layout;
108 const application = new Application(null, {308 const application = new Application(null, {
109- skin: BACKGROUND_SKIN,309+ clip: true,
110 touchCount: 0,310 touchCount: 0,
111- displayListLength: 2048,311+ contents: [port]
112- contents: [header, body, footer]
113 });312 });
114 313
115 return {314 return {
116 application,315 application,
117 render(snapshot) {316 render(snapshot) {
118- setString(header, headerText(snapshot));317+ if (sameSnapshot(port.behavior.snapshot, snapshot)) return;
119- setString(body, bodyText(snapshot, layout.rows));318+ port.behavior.snapshot = snapshot;
120- setString(footer, footerText(snapshot));319+ port.invalidate();
121 },320 },
122 resize(nextScreenInfo) {321 resize(nextScreenInfo) {
123 layout = geometry(nextScreenInfo);322 layout = geometry(nextScreenInfo);
124- header.coordinates = layout.header;323+ port.behavior.layout = layout;
125- body.coordinates = layout.body;324+ port.invalidate();
126- footer.coordinates = layout.footer;325+ },
326+ releasePresentation() {
327+ port.behavior.snapshot = null;
328+ contentStyle = null;
329+ footerStyle = null;
127 },330 },
128 close() {331 close() {
332+ port.behavior.snapshot = null;
333+ contentStyle = null;
334+ footerStyle = null;
129 application.empty();335 application.empty();
130 }336 }
131 };337 };
modified packages/watch/test/controller-storage.test.js +520 -27
@@ -2,12 +2,15 @@ import test from "node:test";
22 import assert from "node:assert/strict";
33 import {
44 KEY_STATUS,
5+ LIMITS,
56 MESSAGE_TYPE,
7+ REQUEST_TRIGGER,
68 SCHEMA_VERSION
79 } from "../src/embeddedjs/contracts.js";
10+import { QUEUE_STATE } from "../src/embeddedjs/message-queue.js";
811 import { createController } from "../src/embeddedjs/controller.js";
912 import { RECEIVE_RESULT } from "../src/embeddedjs/protocol.js";
10-import WatchModel from "../src/embeddedjs/model.js";
13+import WatchModel, { WATCH_STATE } from "../src/embeddedjs/model.js";
1114 import {
1215 WATCH_CONFIGURATION_KEY,
1316 deserializeWatchConfiguration,
@@ -38,6 +41,14 @@ const NEW_FAVORITES = [{
3841 lineLabel: "Métro 9",
3942 destinationLabel: "Pont de Sèvres",
4043 sortOrder: 1
44+}, {
45+ id: "park",
46+ serviceId: "opaque:service:park",
47+ displayName: "Parc",
48+ stopLabel: "Vincennes",
49+ lineLabel: "RER A",
50+ destinationLabel: "Marne-la-Vallée",
51+ sortOrder: 2
4152 }];
4253
4354 class StringStorage {
@@ -64,29 +75,96 @@ class StringStorage {
6475 }
6576 }
6677
78+class Clock {
79+ constructor(now = 1_788_000_000_000) {
80+ this.value = now;
81+ }
82+
83+ now() {
84+ return this.value;
85+ }
86+
87+ advance(milliseconds) {
88+ this.value += milliseconds;
89+ }
90+}
91+
92+class Scheduler {
93+ constructor(clock) {
94+ this.clock = clock;
95+ this.nextId = 1;
96+ this.lastId = null;
97+ this.tasks = [];
98+ }
99+
100+ set(callback, delayMs) {
101+ const task = {
102+ callback,
103+ cleared: false,
104+ dueAt: this.clock.now() + delayMs,
105+ id: this.nextId
106+ };
107+ this.nextId += 1;
108+ this.lastId = task.id;
109+ this.tasks.push(task);
110+ return task.id;
111+ }
112+
113+ clear(timerId) {
114+ const task = this.tasks.find((candidate) => candidate.id === timerId);
115+ if (task) task.cleared = true;
116+ }
117+
118+ advance(milliseconds) {
119+ this.clock.advance(milliseconds);
120+ const ready = this.tasks
121+ .filter((task) => !task.cleared && task.dueAt <= this.clock.now())
122+ .sort((left, right) => left.dueAt - right.dueAt);
123+ ready.forEach((task) => {
124+ task.cleared = true;
125+ task.callback();
126+ });
127+ }
128+
129+ fire(timerId) {
130+ const task = this.tasks.find((candidate) => candidate.id === timerId);
131+ if (task) task.callback();
132+ }
133+}
134+
67135 class Queue {
68136 constructor() {
137+ this.accept = true;
138+ this.attempts = [];
69139 this.messages = [];
140+ this.closed = 0;
70141 }
71142
72143 enqueue(message) {
144+ this.attempts.push(message);
145+ if (!this.accept) return false;
73146 this.messages.push(message);
74147 return true;
75148 }
76149
77- close() {}
150+ close() {
151+ this.closed += 1;
152+ }
78153 }
79154
80155 class View {
81156 constructor() {
82157 this.renders = [];
158+ this.closed = 0;
83159 }
84160
85161 render(snapshot) {
86162 this.renders.push(snapshot);
87163 }
88164
89- close() {}
165+ close() {
166+ this.closed += 1;
167+ }
90168 }
91169
92170 function stored(favorites = [OLD_FAVORITE], language = "fr") {
@@ -105,12 +183,17 @@ function wire(type, entries = []) {
105183 ]);
106184 }
107185
108-function configurationMessages(id, favorites = NEW_FAVORITES, language = "en") {
186+function configurationMessages(
187+ id,
188+ favorites = NEW_FAVORITES,
189+ language = "en",
190+ keyStatus = KEY_STATUS.CONFIGURED
191+) {
109192 return [
110193 wire(MESSAGE_TYPE.CONFIG_BEGIN, [
111194 ["REQUEST_ID", id],
112195 ["ITEM_COUNT", favorites.length],
113- ["KEY_STATUS", KEY_STATUS.CONFIGURED],
196+ ["KEY_STATUS", keyStatus],
114197 ["DISPLAY_NAME", language]
115198 ]),
116199 ...favorites.map((favorite, index) => wire(MESSAGE_TYPE.FAVORITE, [
@@ -128,30 +211,97 @@ function configurationMessages(id, favorites = NEW_FAVORITES, language = "en") {
128211 ];
129212 }
130213
131-function harness(storage) {
214+function resultMessages(
215+ requestId,
216+ favoriteId,
217+ fetchedAt = 1_788_000_000
218+) {
219+ return [
220+ wire(MESSAGE_TYPE.RESULT_BEGIN, [
221+ ["REQUEST_ID", requestId],
222+ ["FAVORITE_ID", favoriteId],
223+ ["ITEM_COUNT", 1],
224+ ["FETCHED_AT", fetchedAt],
225+ ["FRESHNESS", 0]
226+ ]),
227+ wire(MESSAGE_TYPE.DEPARTURE, [
228+ ["REQUEST_ID", requestId],
229+ ["FAVORITE_ID", favoriteId],
230+ ["ITEM_INDEX", 0],
231+ ["EXPECTED_AT", 1_788_000_120],
232+ ["MINUTES", 2],
233+ ["DEPARTURE_STATUS", 0]
234+ ]),
235+ wire(MESSAGE_TYPE.RESULT_COMMIT, [
236+ ["REQUEST_ID", requestId],
237+ ["FAVORITE_ID", favoriteId]
238+ ])
239+ ];
240+}
241+
242+function deliver(controller, messages) {
243+ return messages.map((message) => controller.onReadable(message));
244+}
245+
246+function harness(storage = null) {
247+ const clock = new Clock();
248+ const scheduler = new Scheduler(clock);
132249 const queue = new Queue();
133250 const view = new View();
134251 const controller = createController({
135- clock: { now: () => 1_788_000_000_000 },
252+ clock,
136253 queue,
254+ scheduler,
137255 model: new WatchModel(),
138256 storage,
139257 view
140258 }).start();
141- return { controller, queue, view };
259+ return { clock, controller, queue, scheduler, view };
142260 }
143261
144-test("stored configuration renders offline and refreshes after phone synchronization", () => {
262+test("controller requires the one-shot scheduler adapter", () => {
263+ const adapters = {
264+ clock: new Clock(),
265+ model: new WatchModel(),
266+ queue: new Queue(),
267+ view: new View()
268+ };
269+ assert.throws(() => createController(adapters), /controller adapters are required/u);
270+ assert.throws(() => createController({
271+ ...adapters,
272+ scheduler: { set() {} }
273+ }), /controller adapters are required/u);
274+});
275+
276+test("restored metadata waits for the first phone commit before one app-open request", () => {
145277 const storage = new StringStorage(stored());
146- const first = harness(storage);
147- assert.equal(first.view.renders.at(-1).favorite.id, OLD_FAVORITE.id);
148- assert.equal(first.view.renders.at(-1).language, "fr");
149- assert.equal(first.queue.messages.length, 0);
150- first.controller.onQueueState({ type: "writable" });
151- assert.equal(first.queue.messages.length, 1);
152-
153- configurationMessages("edit-1").forEach(first.controller.onReadable);
154- assert.equal(first.view.renders.at(-1).favorite.id, NEW_FAVORITES[0].id);
278+ const target = harness(storage);
279+ assert.equal(target.view.renders.at(-1).activeFavorite.id, OLD_FAVORITE.id);
280+ assert.equal(target.view.renders.at(-1).language, "fr");
281+ assert.equal(target.queue.messages.length, 0);
282+
283+ target.controller.onQueueState({ type: QUEUE_STATE.WRITABLE });
284+ assert.equal(target.queue.messages.length, 0);
285+
286+ const firstCommit = deliver(
287+ target.controller,
288+ configurationMessages("sync-1", [OLD_FAVORITE], "fr")
289+ );
290+ target.scheduler.advance(0);
291+ assert.equal(firstCommit.at(-1), RECEIVE_RESULT.CONFIG_COMMITTED);
292+ assert.equal(target.queue.messages.length, 1);
293+ assert.equal(target.queue.messages[0].get("MESSAGE_TYPE"), MESSAGE_TYPE.REQUEST);
294+ assert.equal(target.queue.messages[0].get("FAVORITE_ID"), OLD_FAVORITE.id);
295+ assert.equal(target.queue.messages[0].get("REQUEST_TRIGGER"), REQUEST_TRIGGER.APP_OPEN);
296+ assert.equal(
297+ target.queue.messages[0].get("REQUEST_ID"),
298+ "consult-" + Math.floor(target.clock.now() / 1000).toString(36) + "-1"
299+ );
300+
301+ deliver(target.controller, configurationMessages("edit-1"));
302+ target.scheduler.advance(0);
303+ assert.equal(target.queue.messages.length, 1);
304+ assert.equal(target.view.renders.at(-1).activeFavorite.id, NEW_FAVORITES[0].id);
155305 assert.deepEqual(deserializeWatchConfiguration(storage.value), {
156306 keyStatus: KEY_STATUS.CONFIGURED,
157307 language: "en",
@@ -159,11 +309,34 @@ test("stored configuration renders offline and refreshes after phone synchroniza
159309 });
160310
161311 const restarted = harness(storage);
162- assert.equal(restarted.view.renders.at(-1).favorite.id, NEW_FAVORITES[0].id);
163- assert.equal(restarted.view.renders.at(-1).language, "en");
312+ assert.equal(restarted.view.renders.at(-1).activeFavorite.id, NEW_FAVORITES[0].id);
313+ assert.equal(restarted.queue.messages.length, 0);
314+ restarted.controller.onQueueState({ type: QUEUE_STATE.WRITABLE });
164315 assert.equal(restarted.queue.messages.length, 0);
165- restarted.controller.onQueueState({ type: "writable" });
316+ deliver(restarted.controller, configurationMessages("sync-2"));
317+ restarted.scheduler.advance(0);
166318 assert.equal(restarted.queue.messages.length, 1);
319+ assert.equal(
320+ restarted.queue.messages[0].get("REQUEST_TRIGGER"),
321+ REQUEST_TRIGGER.APP_OPEN
322+ );
323+});
324+
325+test("an unconfigured phone commit does not consume the later valid app-open request", () => {
326+ const target = harness();
327+ deliver(
328+ target.controller,
329+ configurationMessages("missing", [], "fr", KEY_STATUS.MISSING)
330+ );
331+ assert.equal(target.queue.messages.length, 0);
332+
333+ deliver(target.controller, configurationMessages("configured"));
334+ assert.equal(target.queue.messages.length, 1);
335+ assert.equal(target.queue.messages[0].get("FAVORITE_ID"), NEW_FAVORITES[0].id);
336+ assert.equal(target.queue.messages[0].get("REQUEST_TRIGGER"), REQUEST_TRIGGER.APP_OPEN);
337+
338+ deliver(target.controller, configurationMessages("later-edit", [OLD_FAVORITE]));
339+ assert.equal(target.queue.messages.length, 1);
167340 });
168341
169342 test("every interrupted sequence position preserves the prior durable list on restart", () => {
@@ -172,16 +345,16 @@ test("every interrupted sequence position preserves the prior durable list on re
172345 const previous = stored();
173346 const storage = new StringStorage(previous);
174347 const current = harness(storage);
175- messages.slice(0, delivered).forEach(current.controller.onReadable);
348+ deliver(current.controller, messages.slice(0, delivered));
176349 assert.equal(storage.value, previous, "delivery prefix " + delivered);
177350
178351 const restarted = harness(storage);
179- assert.equal(restarted.view.renders.at(-1).favorite.id, OLD_FAVORITE.id);
352+ assert.equal(restarted.view.renders.at(-1).activeFavorite.id, OLD_FAVORITE.id);
180353 assert.equal(restarted.view.renders.at(-1).language, "fr");
181354 }
182355 });
183356
184-test("unsupported input and failed persistence roll back to the prior configuration", () => {
357+test("unsupported input and failed persistence roll back without an app-open request", () => {
185358 const previous = stored();
186359 const storage = new StringStorage(previous);
187360 const target = harness(storage);
@@ -191,9 +364,329 @@ test("unsupported input and failed persistence roll back to the prior configurat
191364 assert.equal(storage.value, previous);
192365
193366 storage.failNextSet = true;
194- const results = configurationMessages("failed-save").map(target.controller.onReadable);
195- assert.equal(results.at(-1), RECEIVE_RESULT.REJECTED);
367+ const failed = deliver(target.controller, configurationMessages("failed-save"));
368+ assert.equal(failed.at(-1), RECEIVE_RESULT.CONFIG_COMMITTED);
196369 assert.equal(storage.value, previous);
197- assert.equal(target.view.renders.at(-1).favorite.id, OLD_FAVORITE.id);
370+ assert.equal(target.queue.messages.length, 0);
371+ target.scheduler.advance(0);
372+ assert.equal(storage.value, previous);
373+ assert.equal(target.queue.messages.length, 0);
374+ assert.equal(target.view.renders.at(-1).activeFavorite.id, OLD_FAVORITE.id);
198375 assert.equal(target.view.renders.at(-1).language, "fr");
376+
377+ deliver(target.controller, configurationMessages("successful-save"));
378+ target.scheduler.advance(0);
379+ assert.equal(target.queue.messages.length, 1);
380+ assert.deepEqual(deserializeWatchConfiguration(storage.value), {
381+ keyStatus: KEY_STATUS.CONFIGURED,
382+ language: "en",
383+ favorites: NEW_FAVORITES
384+ });
385+});
386+
387+test("failed compatible persistence restores the complete READY state", () => {
388+ const storage = new StringStorage(stored());
389+ const target = harness(storage);
390+ deliver(
391+ target.controller,
392+ configurationMessages("sync-ready", [OLD_FAVORITE], "fr")
393+ );
394+ target.scheduler.advance(0);
395+ const appOpen = target.queue.messages[0];
396+ deliver(target.controller, resultMessages(
397+ appOpen.get("REQUEST_ID"),
398+ appOpen.get("FAVORITE_ID")
399+ ));
400+ const ready = target.view.renders.at(-1);
401+ const messageCount = target.queue.messages.length;
402+ const previousBytes = storage.value;
403+ assert.equal(ready.state, WATCH_STATE.READY);
404+
405+ storage.failNextSet = true;
406+ deliver(
407+ target.controller,
408+ configurationMessages("failed-compatible", [OLD_FAVORITE], "en")
409+ );
410+ target.scheduler.advance(0);
411+
412+ const rolledBack = target.view.renders.at(-1);
413+ assert.equal(storage.value, previousBytes);
414+ assert.equal(rolledBack.state, WATCH_STATE.READY);
415+ assert.equal(rolledBack.language, "fr");
416+ assert.deepEqual(rolledBack.result, ready.result);
417+ assert.deepEqual(rolledBack.error, ready.error);
418+ assert.equal(rolledBack.expectedRequest, null);
419+ assert.equal(target.queue.messages.length, messageCount);
420+});
421+
422+test("deferred persistence is inert after close and a newer commit supersedes it", () => {
423+ const previous = stored();
424+ const storage = new StringStorage(previous);
425+ const closing = harness(storage);
426+ deliver(closing.controller, configurationMessages("closing"));
427+ const closingTimer = closing.scheduler.lastId;
428+ closing.controller.close();
429+ closing.scheduler.fire(closingTimer);
430+ assert.equal(storage.value, previous);
431+ assert.equal(closing.queue.messages.length, 0);
432+
433+ const replacing = harness(new StringStorage(previous));
434+ deliver(replacing.controller, configurationMessages("first", [OLD_FAVORITE], "fr"));
435+ const firstTimer = replacing.scheduler.lastId;
436+ deliver(replacing.controller, configurationMessages("second", NEW_FAVORITES, "en"));
437+ const secondTimer = replacing.scheduler.lastId;
438+ replacing.scheduler.fire(firstTimer);
439+ assert.equal(replacing.queue.messages.length, 0);
440+ replacing.scheduler.fire(secondTimer);
441+ assert.equal(replacing.queue.messages.length, 1);
442+ assert.equal(replacing.view.renders.at(-1).activeFavorite.id, NEW_FAVORITES[0].id);
443+});
444+
445+test("selection settles at exactly 500 ms and boundary presses are inert", () => {
446+ const target = harness();
447+ deliver(target.controller, configurationMessages("config"));
448+ const baseline = target.queue.messages.length;
449+
450+ target.controller.onButton("up");
451+ target.scheduler.advance(LIMITS.favoriteSettleMs);
452+ assert.equal(target.queue.messages.length, baseline);
453+
454+ target.controller.onButton("down");
455+ assert.equal(
456+ target.view.renders.at(-1).activeFavorite.id,
457+ NEW_FAVORITES[1].id
458+ );
459+ assert.equal(target.view.renders.at(-1).expectedRequest, null);
460+
461+ target.scheduler.advance(LIMITS.favoriteSettleMs - 1);
462+ assert.equal(target.queue.messages.length, baseline);
463+ target.scheduler.advance(1);
464+ assert.equal(target.queue.messages.length, baseline + 1);
465+ const request = target.queue.messages.at(-1);
466+ assert.equal(request.get("FAVORITE_ID"), NEW_FAVORITES[1].id);
467+ assert.equal(request.get("REQUEST_TRIGGER"), REQUEST_TRIGGER.FAVORITE_SELECTION);
468+ assert.match(request.get("REQUEST_ID"), /^consult-[0-9a-z]+-2$/u);
469+});
470+
471+test("rapid navigation sends only the final settled favorite", () => {
472+ const target = harness();
473+ deliver(target.controller, configurationMessages("config"));
474+ const baseline = target.queue.messages.length;
475+
476+ target.controller.onButton("down");
477+ target.scheduler.advance(100);
478+ target.controller.onButton("down");
479+ target.scheduler.advance(100);
480+ target.controller.onButton("up");
481+ target.scheduler.advance(100);
482+ target.controller.onButton("down");
483+ target.controller.onButton("down");
484+
485+ assert.equal(
486+ target.view.renders.at(-1).activeFavorite.id,
487+ NEW_FAVORITES[2].id
488+ );
489+ target.scheduler.advance(LIMITS.favoriteSettleMs - 1);
490+ assert.equal(target.queue.messages.length, baseline);
491+ target.scheduler.advance(1);
492+ assert.equal(target.queue.messages.length, baseline + 1);
493+ assert.equal(
494+ target.queue.messages.at(-1).get("FAVORITE_ID"),
495+ NEW_FAVORITES[2].id
496+ );
497+ assert.equal(
498+ target.queue.messages.at(-1).get("REQUEST_TRIGGER"),
499+ REQUEST_TRIGGER.FAVORITE_SELECTION
500+ );
501+});
502+
503+test("Select cancels settlement and immediately requests only the active favorite", () => {
504+ const target = harness();
505+ deliver(target.controller, configurationMessages("config"));
506+ const baseline = target.queue.messages.length;
507+
508+ target.controller.onButton("down");
509+ const cancelledTimer = target.scheduler.lastId;
510+ target.scheduler.advance(LIMITS.favoriteSettleMs - 1);
511+ target.controller.onButton("select");
512+
513+ assert.equal(target.queue.messages.length, baseline + 1);
514+ assert.equal(
515+ target.queue.messages.at(-1).get("FAVORITE_ID"),
516+ NEW_FAVORITES[1].id
517+ );
518+ assert.equal(
519+ target.queue.messages.at(-1).get("REQUEST_TRIGGER"),
520+ REQUEST_TRIGGER.MANUAL_SELECT
521+ );
522+ target.scheduler.advance(1);
523+ target.scheduler.fire(cancelledTimer);
524+ assert.equal(target.queue.messages.length, baseline + 1);
525+});
526+
527+test("an older handed-off result survives many same-favorite supersessions", () => {
528+ const target = harness();
529+ deliver(target.controller, configurationMessages("config"));
530+ const appOpen = target.queue.messages[0];
531+ deliver(target.controller, resultMessages(
532+ appOpen.get("REQUEST_ID"),
533+ appOpen.get("FAVORITE_ID")
534+ ));
535+
536+ target.controller.onButton("select");
537+ const oldest = target.queue.messages.at(-1);
538+ for (let index = 0; index < LIMITS.requestQueue + 8; index += 1) {
539+ target.controller.onButton("select");
540+ }
541+ const latest = target.queue.messages.at(-1);
542+ assert.notEqual(latest.get("REQUEST_ID"), oldest.get("REQUEST_ID"));
543+
544+ target.clock.advance(30_000);
545+ assert.deepEqual(deliver(target.controller, resultMessages(
546+ oldest.get("REQUEST_ID"),
547+ oldest.get("FAVORITE_ID"),
548+ 1_788_000_030
549+ )), [
550+ RECEIVE_RESULT.STAGED,
551+ RECEIVE_RESULT.STAGED,
552+ RECEIVE_RESULT.RESULT_COMMITTED
553+ ]);
554+ assert.equal(target.view.renders.at(-1).state, WATCH_STATE.READY);
555+ assert.equal(
556+ target.view.renders.at(-1).result.requestId,
557+ oldest.get("REQUEST_ID")
558+ );
559+
560+ target.clock.advance(30_000);
561+ assert.deepEqual(deliver(target.controller, resultMessages(
562+ latest.get("REQUEST_ID"),
563+ latest.get("FAVORITE_ID"),
564+ 1_788_000_060
565+ )), [
566+ RECEIVE_RESULT.STAGED,
567+ RECEIVE_RESULT.STAGED,
568+ RECEIVE_RESULT.RESULT_COMMITTED
569+ ]);
570+ assert.equal(
571+ target.view.renders.at(-1).result.requestId,
572+ latest.get("REQUEST_ID")
573+ );
574+ assert.equal(
575+ target.controller.onReadable(resultMessages(
576+ oldest.get("REQUEST_ID"),
577+ oldest.get("FAVORITE_ID"),
578+ 1_788_000_030
579+ )[0]),
580+ RECEIVE_RESULT.REJECTED
581+ );
582+});
583+
584+test("minute and focus callbacks work as direct listeners without queue writes", () => {
585+ const target = harness();
586+ const onMinuteChange = target.controller.onMinuteChange;
587+ const setActive = target.controller.setActive;
588+ deliver(target.controller, configurationMessages("config"));
589+ const writes = target.queue.messages.length;
590+ const renders = target.view.renders.length;
591+
592+ target.clock.advance(60_000);
593+ onMinuteChange();
594+ assert.equal(target.queue.messages.length, writes);
595+ assert.equal(target.view.renders.length, renders + 1);
596+ assert.equal(target.view.renders.at(-1).nowMs, target.clock.now());
597+
598+ setActive(false);
599+ target.clock.advance(60_000);
600+ onMinuteChange();
601+ assert.equal(target.queue.messages.length, writes);
602+ assert.equal(target.view.renders.length, renders + 1);
603+
604+ setActive(true);
605+ onMinuteChange();
606+ assert.equal(target.queue.messages.length, writes);
607+ assert.equal(target.view.renders.length, renders + 2);
608+ assert.equal(target.view.renders.at(-1).nowMs, target.clock.now());
609+});
610+
611+test("favorite capture, focus loss, and close make late settlement callbacks inert", () => {
612+ const target = harness();
613+ deliver(target.controller, configurationMessages("config"));
614+ const baseline = target.queue.messages.length;
615+
616+ target.controller.onButton("down");
617+ deliver(target.controller, configurationMessages(
618+ "removed-active",
619+ [NEW_FAVORITES[0], NEW_FAVORITES[2]]
620+ ));
621+ target.scheduler.advance(LIMITS.favoriteSettleMs);
622+ assert.equal(target.queue.messages.length, baseline);
623+ assert.equal(
624+ target.view.renders.at(-1).activeFavorite.id,
625+ NEW_FAVORITES[0].id
626+ );
627+
628+ target.controller.onButton("down");
629+ const focusTimer = target.scheduler.lastId;
630+ target.controller.setActive(false);
631+ target.scheduler.fire(focusTimer);
632+ assert.equal(target.queue.messages.length, baseline);
633+
634+ target.controller.setActive(true);
635+ target.controller.onButton("up");
636+ const closeTimer = target.scheduler.lastId;
637+ const renders = target.view.renders.length;
638+ target.controller.close();
639+ target.scheduler.fire(closeTimer);
640+ target.controller.onMinuteChange();
641+ target.controller.onButton("select");
642+ assert.equal(target.queue.messages.length, baseline);
643+ assert.equal(target.view.renders.length, renders);
644+ assert.equal(target.queue.closed, 1);
645+ assert.equal(target.view.closed, 1);
646+});
647+
648+test("enqueue failure cancels both expectations and consumes the app-open attempt", () => {
649+ const target = harness();
650+ target.queue.accept = false;
651+ deliver(target.controller, configurationMessages("config"));
652+
653+ assert.equal(target.queue.attempts.length, 1);
654+ assert.equal(target.queue.messages.length, 0);
655+ const failedRequest = target.queue.attempts[0];
656+ const failedSnapshots = target.view.renders.filter((snapshot) => snapshot.sendFailed);
657+ assert.equal(failedSnapshots.length, 1);
658+ assert.equal(failedSnapshots[0].expectedRequest, null);
659+
660+ const late = deliver(target.controller, resultMessages(
661+ failedRequest.get("REQUEST_ID"),
662+ failedRequest.get("FAVORITE_ID")
663+ ));
664+ assert.deepEqual(late, [
665+ RECEIVE_RESULT.REJECTED,
666+ RECEIVE_RESULT.REJECTED,
667+ RECEIVE_RESULT.REJECTED
668+ ]);
669+ assert.equal(target.view.renders.at(-1).result, null);
670+
671+ target.queue.accept = true;
672+ deliver(target.controller, configurationMessages("later-config"));
673+ assert.equal(target.queue.attempts.length, 1);
674+});
675+
676+test("queue failure cancels a sent request before any late result can commit", () => {
677+ const target = harness();
678+ deliver(target.controller, configurationMessages("config"));
679+ const request = target.queue.messages[0];
680+
681+ target.controller.onQueueState({ type: QUEUE_STATE.FAILED });
682+ assert.equal(target.view.renders.at(-1).sendFailed, true);
683+ assert.equal(target.view.renders.at(-1).expectedRequest, null);
684+ assert.equal(
685+ target.controller.onReadable(resultMessages(
686+ request.get("REQUEST_ID"),
687+ request.get("FAVORITE_ID")
688+ )[0]),
689+ RECEIVE_RESULT.REJECTED
690+ );
691+ assert.equal(target.view.renders.at(-1).result, null);
199692 });
@@ -2,12 +2,15 @@ import test from "node:test";
2 import assert from "node:assert/strict";2 import assert from "node:assert/strict";
3 import {3 import {
4 KEY_STATUS,4 KEY_STATUS,
5+ LIMITS,
5 MESSAGE_TYPE,6 MESSAGE_TYPE,
7+ REQUEST_TRIGGER,
6 SCHEMA_VERSION8 SCHEMA_VERSION
7 } from "../src/embeddedjs/contracts.js";9 } from "../src/embeddedjs/contracts.js";
10+import { QUEUE_STATE } from "../src/embeddedjs/message-queue.js";
8 import { createController } from "../src/embeddedjs/controller.js";11 import { createController } from "../src/embeddedjs/controller.js";
9 import { RECEIVE_RESULT } from "../src/embeddedjs/protocol.js";12 import { RECEIVE_RESULT } from "../src/embeddedjs/protocol.js";
10-import WatchModel from "../src/embeddedjs/model.js";13+import WatchModel, { WATCH_STATE } from "../src/embeddedjs/model.js";
11 import {14 import {
12 WATCH_CONFIGURATION_KEY,15 WATCH_CONFIGURATION_KEY,
13 deserializeWatchConfiguration,16 deserializeWatchConfiguration,
@@ -38,6 +41,14 @@ const NEW_FAVORITES = [{
38 lineLabel: "Métro 9",41 lineLabel: "Métro 9",
39 destinationLabel: "Pont de Sèvres",42 destinationLabel: "Pont de Sèvres",
40 sortOrder: 143 sortOrder: 1
44+}, {
45+ id: "park",
46+ serviceId: "opaque:service:park",
47+ displayName: "Parc",
48+ stopLabel: "Vincennes",
49+ lineLabel: "RER A",
50+ destinationLabel: "Marne-la-Vallée",
51+ sortOrder: 2
41 }];52 }];
42 53
43 class StringStorage {54 class StringStorage {
@@ -64,29 +75,96 @@ class StringStorage {
64 }75 }
65 }76 }
66 77
78+class Clock {
79+ constructor(now = 1_788_000_000_000) {
80+ this.value = now;
81+ }
82+
83+ now() {
84+ return this.value;
85+ }
86+
87+ advance(milliseconds) {
88+ this.value += milliseconds;
89+ }
90+}
91+
92+class Scheduler {
93+ constructor(clock) {
94+ this.clock = clock;
95+ this.nextId = 1;
96+ this.lastId = null;
97+ this.tasks = [];
98+ }
99+
100+ set(callback, delayMs) {
101+ const task = {
102+ callback,
103+ cleared: false,
104+ dueAt: this.clock.now() + delayMs,
105+ id: this.nextId
106+ };
107+ this.nextId += 1;
108+ this.lastId = task.id;
109+ this.tasks.push(task);
110+ return task.id;
111+ }
112+
113+ clear(timerId) {
114+ const task = this.tasks.find((candidate) => candidate.id === timerId);
115+ if (task) task.cleared = true;
116+ }
117+
118+ advance(milliseconds) {
119+ this.clock.advance(milliseconds);
120+ const ready = this.tasks
121+ .filter((task) => !task.cleared && task.dueAt <= this.clock.now())
122+ .sort((left, right) => left.dueAt - right.dueAt);
123+ ready.forEach((task) => {
124+ task.cleared = true;
125+ task.callback();
126+ });
127+ }
128+
129+ fire(timerId) {
130+ const task = this.tasks.find((candidate) => candidate.id === timerId);
131+ if (task) task.callback();
132+ }
133+}
134+
67 class Queue {135 class Queue {
68 constructor() {136 constructor() {
137+ this.accept = true;
138+ this.attempts = [];
69 this.messages = [];139 this.messages = [];
140+ this.closed = 0;
70 }141 }
71 142
72 enqueue(message) {143 enqueue(message) {
144+ this.attempts.push(message);
145+ if (!this.accept) return false;
73 this.messages.push(message);146 this.messages.push(message);
74 return true;147 return true;
75 }148 }
76 149
77- close() {}150+ close() {
151+ this.closed += 1;
152+ }
78 }153 }
79 154
80 class View {155 class View {
81 constructor() {156 constructor() {
82 this.renders = [];157 this.renders = [];
158+ this.closed = 0;
83 }159 }
84 160
85 render(snapshot) {161 render(snapshot) {
86 this.renders.push(snapshot);162 this.renders.push(snapshot);
87 }163 }
88 164
89- close() {}165+ close() {
166+ this.closed += 1;
167+ }
90 }168 }
91 169
92 function stored(favorites = [OLD_FAVORITE], language = "fr") {170 function stored(favorites = [OLD_FAVORITE], language = "fr") {
@@ -105,12 +183,17 @@ function wire(type, entries = []) {
105 ]);183 ]);
106 }184 }
107 185
108-function configurationMessages(id, favorites = NEW_FAVORITES, language = "en") {186+function configurationMessages(
187+ id,
188+ favorites = NEW_FAVORITES,
189+ language = "en",
190+ keyStatus = KEY_STATUS.CONFIGURED
191+) {
109 return [192 return [
110 wire(MESSAGE_TYPE.CONFIG_BEGIN, [193 wire(MESSAGE_TYPE.CONFIG_BEGIN, [
111 ["REQUEST_ID", id],194 ["REQUEST_ID", id],
112 ["ITEM_COUNT", favorites.length],195 ["ITEM_COUNT", favorites.length],
113- ["KEY_STATUS", KEY_STATUS.CONFIGURED],196+ ["KEY_STATUS", keyStatus],
114 ["DISPLAY_NAME", language]197 ["DISPLAY_NAME", language]
115 ]),198 ]),
116 ...favorites.map((favorite, index) => wire(MESSAGE_TYPE.FAVORITE, [199 ...favorites.map((favorite, index) => wire(MESSAGE_TYPE.FAVORITE, [
@@ -128,30 +211,97 @@ function configurationMessages(id, favorites = NEW_FAVORITES, language = "en") {
128 ];211 ];
129 }212 }
130 213
131-function harness(storage) {214+function resultMessages(
215+ requestId,
216+ favoriteId,
217+ fetchedAt = 1_788_000_000
218+) {
219+ return [
220+ wire(MESSAGE_TYPE.RESULT_BEGIN, [
221+ ["REQUEST_ID", requestId],
222+ ["FAVORITE_ID", favoriteId],
223+ ["ITEM_COUNT", 1],
224+ ["FETCHED_AT", fetchedAt],
225+ ["FRESHNESS", 0]
226+ ]),
227+ wire(MESSAGE_TYPE.DEPARTURE, [
228+ ["REQUEST_ID", requestId],
229+ ["FAVORITE_ID", favoriteId],
230+ ["ITEM_INDEX", 0],
231+ ["EXPECTED_AT", 1_788_000_120],
232+ ["MINUTES", 2],
233+ ["DEPARTURE_STATUS", 0]
234+ ]),
235+ wire(MESSAGE_TYPE.RESULT_COMMIT, [
236+ ["REQUEST_ID", requestId],
237+ ["FAVORITE_ID", favoriteId]
238+ ])
239+ ];
240+}
241+
242+function deliver(controller, messages) {
243+ return messages.map((message) => controller.onReadable(message));
244+}
245+
246+function harness(storage = null) {
247+ const clock = new Clock();
248+ const scheduler = new Scheduler(clock);
132 const queue = new Queue();249 const queue = new Queue();
133 const view = new View();250 const view = new View();
134 const controller = createController({251 const controller = createController({
135- clock: { now: () => 1_788_000_000_000 },252+ clock,
136 queue,253 queue,
254+ scheduler,
137 model: new WatchModel(),255 model: new WatchModel(),
138 storage,256 storage,
139 view257 view
140 }).start();258 }).start();
141- return { controller, queue, view };259+ return { clock, controller, queue, scheduler, view };
142 }260 }
143 261
144-test("stored configuration renders offline and refreshes after phone synchronization", () => {262+test("controller requires the one-shot scheduler adapter", () => {
263+ const adapters = {
264+ clock: new Clock(),
265+ model: new WatchModel(),
266+ queue: new Queue(),
267+ view: new View()
268+ };
269+ assert.throws(() => createController(adapters), /controller adapters are required/u);
270+ assert.throws(() => createController({
271+ ...adapters,
272+ scheduler: { set() {} }
273+ }), /controller adapters are required/u);
274+});
275+
276+test("restored metadata waits for the first phone commit before one app-open request", () => {
145 const storage = new StringStorage(stored());277 const storage = new StringStorage(stored());
146- const first = harness(storage);278+ const target = harness(storage);
147- assert.equal(first.view.renders.at(-1).favorite.id, OLD_FAVORITE.id);279+ assert.equal(target.view.renders.at(-1).activeFavorite.id, OLD_FAVORITE.id);
148- assert.equal(first.view.renders.at(-1).language, "fr");280+ assert.equal(target.view.renders.at(-1).language, "fr");
149- assert.equal(first.queue.messages.length, 0);281+ assert.equal(target.queue.messages.length, 0);
150- first.controller.onQueueState({ type: "writable" });282+
151- assert.equal(first.queue.messages.length, 1);283+ target.controller.onQueueState({ type: QUEUE_STATE.WRITABLE });
152-284+ assert.equal(target.queue.messages.length, 0);
153- configurationMessages("edit-1").forEach(first.controller.onReadable);285+
154- assert.equal(first.view.renders.at(-1).favorite.id, NEW_FAVORITES[0].id);286+ const firstCommit = deliver(
287+ target.controller,
288+ configurationMessages("sync-1", [OLD_FAVORITE], "fr")
289+ );
290+ target.scheduler.advance(0);
291+ assert.equal(firstCommit.at(-1), RECEIVE_RESULT.CONFIG_COMMITTED);
292+ assert.equal(target.queue.messages.length, 1);
293+ assert.equal(target.queue.messages[0].get("MESSAGE_TYPE"), MESSAGE_TYPE.REQUEST);
294+ assert.equal(target.queue.messages[0].get("FAVORITE_ID"), OLD_FAVORITE.id);
295+ assert.equal(target.queue.messages[0].get("REQUEST_TRIGGER"), REQUEST_TRIGGER.APP_OPEN);
296+ assert.equal(
297+ target.queue.messages[0].get("REQUEST_ID"),
298+ "consult-" + Math.floor(target.clock.now() / 1000).toString(36) + "-1"
299+ );
300+
301+ deliver(target.controller, configurationMessages("edit-1"));
302+ target.scheduler.advance(0);
303+ assert.equal(target.queue.messages.length, 1);
304+ assert.equal(target.view.renders.at(-1).activeFavorite.id, NEW_FAVORITES[0].id);
155 assert.deepEqual(deserializeWatchConfiguration(storage.value), {305 assert.deepEqual(deserializeWatchConfiguration(storage.value), {
156 keyStatus: KEY_STATUS.CONFIGURED,306 keyStatus: KEY_STATUS.CONFIGURED,
157 language: "en",307 language: "en",
@@ -159,11 +309,34 @@ test("stored configuration renders offline and refreshes after phone synchroniza
159 });309 });
160 310
161 const restarted = harness(storage);311 const restarted = harness(storage);
162- assert.equal(restarted.view.renders.at(-1).favorite.id, NEW_FAVORITES[0].id);312+ assert.equal(restarted.view.renders.at(-1).activeFavorite.id, NEW_FAVORITES[0].id);
163- assert.equal(restarted.view.renders.at(-1).language, "en");313+ assert.equal(restarted.queue.messages.length, 0);
314+ restarted.controller.onQueueState({ type: QUEUE_STATE.WRITABLE });
164 assert.equal(restarted.queue.messages.length, 0);315 assert.equal(restarted.queue.messages.length, 0);
165- restarted.controller.onQueueState({ type: "writable" });316+ deliver(restarted.controller, configurationMessages("sync-2"));
317+ restarted.scheduler.advance(0);
166 assert.equal(restarted.queue.messages.length, 1);318 assert.equal(restarted.queue.messages.length, 1);
319+ assert.equal(
320+ restarted.queue.messages[0].get("REQUEST_TRIGGER"),
321+ REQUEST_TRIGGER.APP_OPEN
322+ );
323+});
324+
325+test("an unconfigured phone commit does not consume the later valid app-open request", () => {
326+ const target = harness();
327+ deliver(
328+ target.controller,
329+ configurationMessages("missing", [], "fr", KEY_STATUS.MISSING)
330+ );
331+ assert.equal(target.queue.messages.length, 0);
332+
333+ deliver(target.controller, configurationMessages("configured"));
334+ assert.equal(target.queue.messages.length, 1);
335+ assert.equal(target.queue.messages[0].get("FAVORITE_ID"), NEW_FAVORITES[0].id);
336+ assert.equal(target.queue.messages[0].get("REQUEST_TRIGGER"), REQUEST_TRIGGER.APP_OPEN);
337+
338+ deliver(target.controller, configurationMessages("later-edit", [OLD_FAVORITE]));
339+ assert.equal(target.queue.messages.length, 1);
167 });340 });
168 341
169 test("every interrupted sequence position preserves the prior durable list on restart", () => {342 test("every interrupted sequence position preserves the prior durable list on restart", () => {
@@ -172,16 +345,16 @@ test("every interrupted sequence position preserves the prior durable list on re
172 const previous = stored();345 const previous = stored();
173 const storage = new StringStorage(previous);346 const storage = new StringStorage(previous);
174 const current = harness(storage);347 const current = harness(storage);
175- messages.slice(0, delivered).forEach(current.controller.onReadable);348+ deliver(current.controller, messages.slice(0, delivered));
176 assert.equal(storage.value, previous, "delivery prefix " + delivered);349 assert.equal(storage.value, previous, "delivery prefix " + delivered);
177 350
178 const restarted = harness(storage);351 const restarted = harness(storage);
179- assert.equal(restarted.view.renders.at(-1).favorite.id, OLD_FAVORITE.id);352+ assert.equal(restarted.view.renders.at(-1).activeFavorite.id, OLD_FAVORITE.id);
180 assert.equal(restarted.view.renders.at(-1).language, "fr");353 assert.equal(restarted.view.renders.at(-1).language, "fr");
181 }354 }
182 });355 });
183 356
184-test("unsupported input and failed persistence roll back to the prior configuration", () => {357+test("unsupported input and failed persistence roll back without an app-open request", () => {
185 const previous = stored();358 const previous = stored();
186 const storage = new StringStorage(previous);359 const storage = new StringStorage(previous);
187 const target = harness(storage);360 const target = harness(storage);
@@ -191,9 +364,329 @@ test("unsupported input and failed persistence roll back to the prior configurat
191 assert.equal(storage.value, previous);364 assert.equal(storage.value, previous);
192 365
193 storage.failNextSet = true;366 storage.failNextSet = true;
194- const results = configurationMessages("failed-save").map(target.controller.onReadable);367+ const failed = deliver(target.controller, configurationMessages("failed-save"));
195- assert.equal(results.at(-1), RECEIVE_RESULT.REJECTED);368+ assert.equal(failed.at(-1), RECEIVE_RESULT.CONFIG_COMMITTED);
196 assert.equal(storage.value, previous);369 assert.equal(storage.value, previous);
197- assert.equal(target.view.renders.at(-1).favorite.id, OLD_FAVORITE.id);370+ assert.equal(target.queue.messages.length, 0);
371+ target.scheduler.advance(0);
372+ assert.equal(storage.value, previous);
373+ assert.equal(target.queue.messages.length, 0);
374+ assert.equal(target.view.renders.at(-1).activeFavorite.id, OLD_FAVORITE.id);
198 assert.equal(target.view.renders.at(-1).language, "fr");375 assert.equal(target.view.renders.at(-1).language, "fr");
376+
377+ deliver(target.controller, configurationMessages("successful-save"));
378+ target.scheduler.advance(0);
379+ assert.equal(target.queue.messages.length, 1);
380+ assert.deepEqual(deserializeWatchConfiguration(storage.value), {
381+ keyStatus: KEY_STATUS.CONFIGURED,
382+ language: "en",
383+ favorites: NEW_FAVORITES
384+ });
385+});
386+
387+test("failed compatible persistence restores the complete READY state", () => {
388+ const storage = new StringStorage(stored());
389+ const target = harness(storage);
390+ deliver(
391+ target.controller,
392+ configurationMessages("sync-ready", [OLD_FAVORITE], "fr")
393+ );
394+ target.scheduler.advance(0);
395+ const appOpen = target.queue.messages[0];
396+ deliver(target.controller, resultMessages(
397+ appOpen.get("REQUEST_ID"),
398+ appOpen.get("FAVORITE_ID")
399+ ));
400+ const ready = target.view.renders.at(-1);
401+ const messageCount = target.queue.messages.length;
402+ const previousBytes = storage.value;
403+ assert.equal(ready.state, WATCH_STATE.READY);
404+
405+ storage.failNextSet = true;
406+ deliver(
407+ target.controller,
408+ configurationMessages("failed-compatible", [OLD_FAVORITE], "en")
409+ );
410+ target.scheduler.advance(0);
411+
412+ const rolledBack = target.view.renders.at(-1);
413+ assert.equal(storage.value, previousBytes);
414+ assert.equal(rolledBack.state, WATCH_STATE.READY);
415+ assert.equal(rolledBack.language, "fr");
416+ assert.deepEqual(rolledBack.result, ready.result);
417+ assert.deepEqual(rolledBack.error, ready.error);
418+ assert.equal(rolledBack.expectedRequest, null);
419+ assert.equal(target.queue.messages.length, messageCount);
420+});
421+
422+test("deferred persistence is inert after close and a newer commit supersedes it", () => {
423+ const previous = stored();
424+ const storage = new StringStorage(previous);
425+ const closing = harness(storage);
426+ deliver(closing.controller, configurationMessages("closing"));
427+ const closingTimer = closing.scheduler.lastId;
428+ closing.controller.close();
429+ closing.scheduler.fire(closingTimer);
430+ assert.equal(storage.value, previous);
431+ assert.equal(closing.queue.messages.length, 0);
432+
433+ const replacing = harness(new StringStorage(previous));
434+ deliver(replacing.controller, configurationMessages("first", [OLD_FAVORITE], "fr"));
435+ const firstTimer = replacing.scheduler.lastId;
436+ deliver(replacing.controller, configurationMessages("second", NEW_FAVORITES, "en"));
437+ const secondTimer = replacing.scheduler.lastId;
438+ replacing.scheduler.fire(firstTimer);
439+ assert.equal(replacing.queue.messages.length, 0);
440+ replacing.scheduler.fire(secondTimer);
441+ assert.equal(replacing.queue.messages.length, 1);
442+ assert.equal(replacing.view.renders.at(-1).activeFavorite.id, NEW_FAVORITES[0].id);
443+});
444+
445+test("selection settles at exactly 500 ms and boundary presses are inert", () => {
446+ const target = harness();
447+ deliver(target.controller, configurationMessages("config"));
448+ const baseline = target.queue.messages.length;
449+
450+ target.controller.onButton("up");
451+ target.scheduler.advance(LIMITS.favoriteSettleMs);
452+ assert.equal(target.queue.messages.length, baseline);
453+
454+ target.controller.onButton("down");
455+ assert.equal(
456+ target.view.renders.at(-1).activeFavorite.id,
457+ NEW_FAVORITES[1].id
458+ );
459+ assert.equal(target.view.renders.at(-1).expectedRequest, null);
460+
461+ target.scheduler.advance(LIMITS.favoriteSettleMs - 1);
462+ assert.equal(target.queue.messages.length, baseline);
463+ target.scheduler.advance(1);
464+ assert.equal(target.queue.messages.length, baseline + 1);
465+ const request = target.queue.messages.at(-1);
466+ assert.equal(request.get("FAVORITE_ID"), NEW_FAVORITES[1].id);
467+ assert.equal(request.get("REQUEST_TRIGGER"), REQUEST_TRIGGER.FAVORITE_SELECTION);
468+ assert.match(request.get("REQUEST_ID"), /^consult-[0-9a-z]+-2$/u);
469+});
470+
471+test("rapid navigation sends only the final settled favorite", () => {
472+ const target = harness();
473+ deliver(target.controller, configurationMessages("config"));
474+ const baseline = target.queue.messages.length;
475+
476+ target.controller.onButton("down");
477+ target.scheduler.advance(100);
478+ target.controller.onButton("down");
479+ target.scheduler.advance(100);
480+ target.controller.onButton("up");
481+ target.scheduler.advance(100);
482+ target.controller.onButton("down");
483+ target.controller.onButton("down");
484+
485+ assert.equal(
486+ target.view.renders.at(-1).activeFavorite.id,
487+ NEW_FAVORITES[2].id
488+ );
489+ target.scheduler.advance(LIMITS.favoriteSettleMs - 1);
490+ assert.equal(target.queue.messages.length, baseline);
491+ target.scheduler.advance(1);
492+ assert.equal(target.queue.messages.length, baseline + 1);
493+ assert.equal(
494+ target.queue.messages.at(-1).get("FAVORITE_ID"),
495+ NEW_FAVORITES[2].id
496+ );
497+ assert.equal(
498+ target.queue.messages.at(-1).get("REQUEST_TRIGGER"),
499+ REQUEST_TRIGGER.FAVORITE_SELECTION
500+ );
501+});
502+
503+test("Select cancels settlement and immediately requests only the active favorite", () => {
504+ const target = harness();
505+ deliver(target.controller, configurationMessages("config"));
506+ const baseline = target.queue.messages.length;
507+
508+ target.controller.onButton("down");
509+ const cancelledTimer = target.scheduler.lastId;
510+ target.scheduler.advance(LIMITS.favoriteSettleMs - 1);
511+ target.controller.onButton("select");
512+
513+ assert.equal(target.queue.messages.length, baseline + 1);
514+ assert.equal(
515+ target.queue.messages.at(-1).get("FAVORITE_ID"),
516+ NEW_FAVORITES[1].id
517+ );
518+ assert.equal(
519+ target.queue.messages.at(-1).get("REQUEST_TRIGGER"),
520+ REQUEST_TRIGGER.MANUAL_SELECT
521+ );
522+ target.scheduler.advance(1);
523+ target.scheduler.fire(cancelledTimer);
524+ assert.equal(target.queue.messages.length, baseline + 1);
525+});
526+
527+test("an older handed-off result survives many same-favorite supersessions", () => {
528+ const target = harness();
529+ deliver(target.controller, configurationMessages("config"));
530+ const appOpen = target.queue.messages[0];
531+ deliver(target.controller, resultMessages(
532+ appOpen.get("REQUEST_ID"),
533+ appOpen.get("FAVORITE_ID")
534+ ));
535+
536+ target.controller.onButton("select");
537+ const oldest = target.queue.messages.at(-1);
538+ for (let index = 0; index < LIMITS.requestQueue + 8; index += 1) {
539+ target.controller.onButton("select");
540+ }
541+ const latest = target.queue.messages.at(-1);
542+ assert.notEqual(latest.get("REQUEST_ID"), oldest.get("REQUEST_ID"));
543+
544+ target.clock.advance(30_000);
545+ assert.deepEqual(deliver(target.controller, resultMessages(
546+ oldest.get("REQUEST_ID"),
547+ oldest.get("FAVORITE_ID"),
548+ 1_788_000_030
549+ )), [
550+ RECEIVE_RESULT.STAGED,
551+ RECEIVE_RESULT.STAGED,
552+ RECEIVE_RESULT.RESULT_COMMITTED
553+ ]);
554+ assert.equal(target.view.renders.at(-1).state, WATCH_STATE.READY);
555+ assert.equal(
556+ target.view.renders.at(-1).result.requestId,
557+ oldest.get("REQUEST_ID")
558+ );
559+
560+ target.clock.advance(30_000);
561+ assert.deepEqual(deliver(target.controller, resultMessages(
562+ latest.get("REQUEST_ID"),
563+ latest.get("FAVORITE_ID"),
564+ 1_788_000_060
565+ )), [
566+ RECEIVE_RESULT.STAGED,
567+ RECEIVE_RESULT.STAGED,
568+ RECEIVE_RESULT.RESULT_COMMITTED
569+ ]);
570+ assert.equal(
571+ target.view.renders.at(-1).result.requestId,
572+ latest.get("REQUEST_ID")
573+ );
574+ assert.equal(
575+ target.controller.onReadable(resultMessages(
576+ oldest.get("REQUEST_ID"),
577+ oldest.get("FAVORITE_ID"),
578+ 1_788_000_030
579+ )[0]),
580+ RECEIVE_RESULT.REJECTED
581+ );
582+});
583+
584+test("minute and focus callbacks work as direct listeners without queue writes", () => {
585+ const target = harness();
586+ const onMinuteChange = target.controller.onMinuteChange;
587+ const setActive = target.controller.setActive;
588+ deliver(target.controller, configurationMessages("config"));
589+ const writes = target.queue.messages.length;
590+ const renders = target.view.renders.length;
591+
592+ target.clock.advance(60_000);
593+ onMinuteChange();
594+ assert.equal(target.queue.messages.length, writes);
595+ assert.equal(target.view.renders.length, renders + 1);
596+ assert.equal(target.view.renders.at(-1).nowMs, target.clock.now());
597+
598+ setActive(false);
599+ target.clock.advance(60_000);
600+ onMinuteChange();
601+ assert.equal(target.queue.messages.length, writes);
602+ assert.equal(target.view.renders.length, renders + 1);
603+
604+ setActive(true);
605+ onMinuteChange();
606+ assert.equal(target.queue.messages.length, writes);
607+ assert.equal(target.view.renders.length, renders + 2);
608+ assert.equal(target.view.renders.at(-1).nowMs, target.clock.now());
609+});
610+
611+test("favorite capture, focus loss, and close make late settlement callbacks inert", () => {
612+ const target = harness();
613+ deliver(target.controller, configurationMessages("config"));
614+ const baseline = target.queue.messages.length;
615+
616+ target.controller.onButton("down");
617+ deliver(target.controller, configurationMessages(
618+ "removed-active",
619+ [NEW_FAVORITES[0], NEW_FAVORITES[2]]
620+ ));
621+ target.scheduler.advance(LIMITS.favoriteSettleMs);
622+ assert.equal(target.queue.messages.length, baseline);
623+ assert.equal(
624+ target.view.renders.at(-1).activeFavorite.id,
625+ NEW_FAVORITES[0].id
626+ );
627+
628+ target.controller.onButton("down");
629+ const focusTimer = target.scheduler.lastId;
630+ target.controller.setActive(false);
631+ target.scheduler.fire(focusTimer);
632+ assert.equal(target.queue.messages.length, baseline);
633+
634+ target.controller.setActive(true);
635+ target.controller.onButton("up");
636+ const closeTimer = target.scheduler.lastId;
637+ const renders = target.view.renders.length;
638+ target.controller.close();
639+ target.scheduler.fire(closeTimer);
640+ target.controller.onMinuteChange();
641+ target.controller.onButton("select");
642+ assert.equal(target.queue.messages.length, baseline);
643+ assert.equal(target.view.renders.length, renders);
644+ assert.equal(target.queue.closed, 1);
645+ assert.equal(target.view.closed, 1);
646+});
647+
648+test("enqueue failure cancels both expectations and consumes the app-open attempt", () => {
649+ const target = harness();
650+ target.queue.accept = false;
651+ deliver(target.controller, configurationMessages("config"));
652+
653+ assert.equal(target.queue.attempts.length, 1);
654+ assert.equal(target.queue.messages.length, 0);
655+ const failedRequest = target.queue.attempts[0];
656+ const failedSnapshots = target.view.renders.filter((snapshot) => snapshot.sendFailed);
657+ assert.equal(failedSnapshots.length, 1);
658+ assert.equal(failedSnapshots[0].expectedRequest, null);
659+
660+ const late = deliver(target.controller, resultMessages(
661+ failedRequest.get("REQUEST_ID"),
662+ failedRequest.get("FAVORITE_ID")
663+ ));
664+ assert.deepEqual(late, [
665+ RECEIVE_RESULT.REJECTED,
666+ RECEIVE_RESULT.REJECTED,
667+ RECEIVE_RESULT.REJECTED
668+ ]);
669+ assert.equal(target.view.renders.at(-1).result, null);
670+
671+ target.queue.accept = true;
672+ deliver(target.controller, configurationMessages("later-config"));
673+ assert.equal(target.queue.attempts.length, 1);
674+});
675+
676+test("queue failure cancels a sent request before any late result can commit", () => {
677+ const target = harness();
678+ deliver(target.controller, configurationMessages("config"));
679+ const request = target.queue.messages[0];
680+
681+ target.controller.onQueueState({ type: QUEUE_STATE.FAILED });
682+ assert.equal(target.view.renders.at(-1).sendFailed, true);
683+ assert.equal(target.view.renders.at(-1).expectedRequest, null);
684+ assert.equal(
685+ target.controller.onReadable(resultMessages(
686+ request.get("REQUEST_ID"),
687+ request.get("FAVORITE_ID")
688+ )[0]),
689+ RECEIVE_RESULT.REJECTED
690+ );
691+ assert.equal(target.view.renders.at(-1).result, null);
199 });692 });
modified packages/watch/test/fixture-boot.test.js +487 -51
@@ -2,12 +2,19 @@ import test from "node:test";
22 import assert from "node:assert/strict";
33 import { createRequire } from "node:module";
44 import { createController } from "../src/embeddedjs/controller.js";
5-import WatchModel from "../src/embeddedjs/model.js";
5+import WatchModel, { WATCH_STATE } from "../src/embeddedjs/model.js";
6+import { createPresentation } from "../src/embeddedjs/presentation.js";
7+import { RECEIVE_RESULT } from "../src/embeddedjs/protocol.js";
68
79 const require = createRequire(import.meta.url);
810 const companion = require("../../companion/src/index.js");
9-const fixture = require("../../../fixtures/departures/foundation.json");
1011 const fakes = require("../../companion/test/fakes.js");
12+const fixture = require("../../../fixtures/departures/foundation.json");
13+
14+const contracts = companion.contracts;
15+const codec = companion.codec;
16+const T = contracts.MESSAGE_TYPE;
17+const TEST_KEY = "test-personal-prim-key";
1118
1219 class WatchQueue {
1320 constructor() {
@@ -24,11 +31,15 @@ class WatchQueue {
2431
2532 class WatchView {
2633 constructor() {
27- this.snapshots = [];
34+ this.frames = [];
35+ this.screenInfo = { round: false, hour12: false };
2836 }
2937
3038 render(snapshot) {
31- this.snapshots.push(snapshot);
39+ this.frames.push({
40+ snapshot,
41+ presentation: createPresentation(snapshot, this.screenInfo)
42+ });
3243 }
3344
3445 close() {}
@@ -38,65 +49,490 @@ function aliasMap(dictionary) {
3849 return new Map(Object.entries(dictionary));
3950 }
4051
41-test("foundation fixture completes the PKJS to Alloy round trip without a key or XHR", () => {
42- const Pebble = new fakes.FakePebble(true);
52+function messageTypes(messages) {
53+ return messages.map((message) => message.MESSAGE_TYPE);
54+}
55+
56+function expiredResult(clock) {
57+ const result = contracts.copyDepartureResult(fixture.result, "cached-seed");
58+ result.fetchedAt = Math.floor(clock.now() / 1000) - contracts.CACHE_FRESH_SECONDS;
59+ result.sourceUpdatedAt = result.fetchedAt - 10;
60+ return result;
61+}
62+
63+function configuredStorage(result, storedAt) {
4364 const storage = new fakes.FakeStorage();
44- const phoneClock = new fakes.FakeClock(fixture.result.fetchedAt * 1000);
65+ assert.equal(companion.configuration.saveConfiguration(storage, {
66+ schemaVersion: contracts.SCHEMA_VERSION,
67+ favorites: [fixture.favorite],
68+ primApiKey: TEST_KEY,
69+ keyStatus: contracts.KEY_STATUS.CONFIGURED
70+ }), true);
71+ assert.equal(companion.configuration.saveResults(storage, {
72+ schemaVersion: contracts.SCHEMA_VERSION,
73+ results: [{
74+ favoriteId: result.favoriteId,
75+ storedAt,
76+ result
77+ }]
78+ }), true);
79+ storage.writes.length = 0;
80+ return storage;
81+}
82+
83+function harness(options = {}) {
84+ const clock = new fakes.FakeClock(fixture.result.fetchedAt * 1000);
85+ const cachedResult = expiredResult(clock);
86+ const storage = configuredStorage(cachedResult, clock.now());
87+ const manualPhoneTransport = options.manualPhoneTransport === true;
88+ const Pebble = new fakes.FakePebble(!manualPhoneTransport);
89+ const defer = fakes.createDefer(!manualPhoneTransport);
90+ const readyDefer = fakes.createDefer(!manualPhoneTransport);
4591 const xhr = fakes.createXHRFactory();
46- companion.createCompanion({
92+ const phone = companion.createCompanion({
4793 Pebble,
4894 storage,
4995 XHR: xhr.XHR,
50- clock: phoneClock,
51- defer: fakes.createDefer(true),
52- backendUrl: "",
53- configurationUrl: "",
54- fixture
96+ clock,
97+ defer,
98+ readyDefer,
99+ backendUrl: "https://backend.example.test/departures",
100+ configurationUrl: "https://config.example.test/index.html"
55101 });
56-
57102 const watchQueue = new WatchQueue();
58103 const watchView = new WatchView();
104+ const scheduler = {
105+ set(callback, delayMs) {
106+ return clock.setTimeout(callback, delayMs);
107+ },
108+ clear(timerId) {
109+ clock.clearTimeout(timerId);
110+ }
111+ };
59112 const controller = createController({
60- clock: { now: () => phoneClock.now() },
113+ clock,
61114 queue: watchQueue,
62- view: watchView,
63- model: new WatchModel()
115+ scheduler,
116+ model: new WatchModel(),
117+ view: watchView
64118 }).start();
119+ return {
120+ cachedResult,
121+ clock,
122+ controller,
123+ defer,
124+ readyDefer,
125+ Pebble,
126+ phone,
127+ phoneCursor: 0,
128+ storage,
129+ watchCursor: 0,
130+ watchQueue,
131+ watchView,
132+ xhr
133+ };
134+}
135+
136+function takePhoneMessages(target) {
137+ const messages = target.Pebble.sent.slice(target.phoneCursor);
138+ target.phoneCursor = target.Pebble.sent.length;
139+ return messages;
140+}
141+
142+function deliverPhoneMessages(target, messages) {
143+ return messages.map((message) => target.controller.onReadable(aliasMap(message)));
144+}
145+
146+function pendingPhoneMessage(target) {
147+ assert.equal(target.Pebble.pending.length, 1);
148+ const message = target.Pebble.sent[target.phoneCursor];
149+ assert.ok(message);
150+ target.phoneCursor += 1;
151+ return message;
152+}
153+
154+function receiveAndAckPhoneMessage(target) {
155+ const message = pendingPhoneMessage(target);
156+ const received = target.controller.onReadable(aliasMap(message));
157+ target.Pebble.ack();
158+ return { message, received };
159+}
160+
161+function nackPhoneMessage(target) {
162+ const message = pendingPhoneMessage(target);
163+ target.Pebble.fail();
164+ return message;
165+}
166+
167+function advancePhoneQueue(target) {
168+ assert.equal(target.defer.pending.length, 1);
169+ target.defer.runNext();
170+ assert.equal(target.Pebble.pending.length, 1);
171+}
172+
173+function receiveAndAckPhoneSequence(target, types) {
174+ return types.map((type, index) => {
175+ if (index > 0) advancePhoneQueue(target);
176+ const settled = receiveAndAckPhoneMessage(target);
177+ assert.equal(settled.message.MESSAGE_TYPE, type);
178+ return settled;
179+ });
180+}
181+
182+function forwardWatchRequest(target) {
183+ const message = target.watchQueue.messages[target.watchCursor];
184+ assert.ok(message instanceof Map);
185+ target.watchCursor += 1;
186+ const payload = Object.fromEntries(message);
187+ target.Pebble.emit("appmessage", { payload });
188+ return payload;
189+}
65190
66- Pebble.emit("ready");
67- const configurationMessages = Pebble.sent.slice();
68- assert.deepEqual(configurationMessages.map((message) => message.MESSAGE_TYPE), [
69- companion.contracts.MESSAGE_TYPE.CONFIG_BEGIN,
70- companion.contracts.MESSAGE_TYPE.FAVORITE,
71- companion.contracts.MESSAGE_TYPE.CONFIG_COMMIT
191+function beginCachedConsultation(target) {
192+ target.Pebble.emit("ready");
193+ const configurationMessages = takePhoneMessages(target);
194+ assert.deepEqual(messageTypes(configurationMessages), [
195+ T.CONFIG_BEGIN,
196+ T.FAVORITE,
197+ T.CONFIG_COMMIT
72198 ]);
73- assert.equal(configurationMessages[0].KEY_STATUS, companion.contracts.KEY_STATUS.CONFIGURED);
74- assert.equal(configurationMessages[0].DISPLAY_NAME, "en");
75- configurationMessages.forEach((message) => controller.onReadable(aliasMap(message)));
76-
77- assert.equal(watchQueue.messages.length, 1);
78- const request = Object.fromEntries(watchQueue.messages[0]);
79- assert.equal(request.REQUEST_TRIGGER, companion.contracts.REQUEST_TRIGGER.APP_OPEN);
80- assert.equal(Object.keys(request).some((key) => /^\d+$/u.test(key)), false);
81-
82- const resultStart = Pebble.sent.length;
83- Pebble.emit("appmessage", { payload: request });
84- const resultMessages = Pebble.sent.slice(resultStart);
85- assert.deepEqual(resultMessages.map((message) => message.MESSAGE_TYPE), [
86- companion.contracts.MESSAGE_TYPE.RESULT_BEGIN,
87- companion.contracts.MESSAGE_TYPE.DEPARTURE,
88- companion.contracts.MESSAGE_TYPE.DEPARTURE,
89- companion.contracts.MESSAGE_TYPE.RESULT_COMMIT
199+ assert.deepEqual(deliverPhoneMessages(target, configurationMessages), [
200+ RECEIVE_RESULT.STAGED,
201+ RECEIVE_RESULT.STAGED,
202+ RECEIVE_RESULT.CONFIG_COMMITTED
203+ ]);
204+
205+ assert.equal(target.watchQueue.messages.length, 1);
206+ const request = forwardWatchRequest(target);
207+ assert.equal(request.MESSAGE_TYPE, T.REQUEST);
208+ assert.equal(request.FAVORITE_ID, fixture.favorite.id);
209+ assert.equal(request.REQUEST_TRIGGER, contracts.REQUEST_TRIGGER.APP_OPEN);
210+ assert.equal(target.xhr.instances.length, 1);
211+
212+ const cachedMessages = takePhoneMessages(target);
213+ assert.deepEqual(messageTypes(cachedMessages), [
214+ T.RESULT_BEGIN,
215+ T.DEPARTURE,
216+ T.DEPARTURE,
217+ T.RESULT_COMMIT,
218+ T.REQUEST
219+ ]);
220+ const rendersBeforeCache = target.watchView.frames.length;
221+ assert.deepEqual(deliverPhoneMessages(target, cachedMessages), [
222+ RECEIVE_RESULT.STAGED,
223+ RECEIVE_RESULT.STAGED,
224+ RECEIVE_RESULT.STAGED,
225+ RECEIVE_RESULT.RESULT_COMMITTED,
226+ RECEIVE_RESULT.STAGED
227+ ]);
228+ assert.equal(target.watchView.frames.length, rendersBeforeCache + 1);
229+
230+ const cachedFrame = target.watchView.frames.at(-1);
231+ assert.equal(cachedFrame.snapshot.state, WATCH_STATE.STALE);
232+ assert.equal(cachedFrame.snapshot.result.requestId, request.REQUEST_ID);
233+ assert.equal(cachedFrame.snapshot.result.favoriteId, fixture.favorite.id);
234+ assert.equal(target.xhr.instances[0].status, 0);
235+ assert.equal(target.xhr.instances[0].responseText, "");
236+ return { cachedFrame, request };
237+}
238+
239+test("expired cache commits before one changed live result replaces it atomically", (t) => {
240+ const target = harness();
241+ t.after(() => {
242+ target.phone.stop();
243+ target.controller.close();
244+ });
245+ const { cachedFrame, request } = beginCachedConsultation(target);
246+ const changedLive = contracts.copyDepartureResult(fixture.result, request.REQUEST_ID);
247+ changedLive.fetchedAt = Math.floor(target.clock.now() / 1000);
248+ changedLive.sourceUpdatedAt = changedLive.fetchedAt - 1;
249+ changedLive.departures[0].expectedAt += 60;
250+ changedLive.departures[0].minutes += 1;
251+
252+ const interrupted = codec.encodeResult(changedLive);
253+ const mismatchedDeparture = {
254+ ...interrupted[2],
255+ REQUEST_ID: "mismatched-live"
256+ };
257+ const cachedFrameCount = target.watchView.frames.length;
258+ assert.deepEqual([
259+ target.controller.onReadable(aliasMap(interrupted[0])),
260+ target.controller.onReadable(aliasMap(interrupted[1])),
261+ target.controller.onReadable(aliasMap(mismatchedDeparture)),
262+ target.controller.onReadable(aliasMap(interrupted.at(-1)))
263+ ], [
264+ RECEIVE_RESULT.STAGED,
265+ RECEIVE_RESULT.STAGED,
266+ RECEIVE_RESULT.REJECTED,
267+ RECEIVE_RESULT.REJECTED
90268 ]);
91- resultMessages.forEach((message) => controller.onReadable(aliasMap(message)));
92-
93- const finalSnapshot = watchView.snapshots.at(-1);
94- assert.equal(finalSnapshot.favorite.id, fixture.favorite.id);
95- assert.equal(finalSnapshot.result.favoriteId, fixture.result.favoriteId);
96- assert.equal(finalSnapshot.result.departures.length, 2);
97- assert.equal(finalSnapshot.result.departures[0].minutes, fixture.result.departures[0].minutes);
98- assert.equal(finalSnapshot.sendFailed, false);
99- assert.equal(xhr.instances.length, 0);
100- assert.equal(storage.writes.length, 0);
101- controller.close();
269+ assert.equal(target.watchView.frames.length, cachedFrameCount);
270+ assert.deepEqual(target.watchView.frames.at(-1).presentation, cachedFrame.presentation);
271+
272+ target.xhr.instances[0].respond(200, changedLive);
273+ const liveMessages = takePhoneMessages(target);
274+ assert.deepEqual(messageTypes(liveMessages), [
275+ T.RESULT_BEGIN,
276+ T.DEPARTURE,
277+ T.DEPARTURE,
278+ T.RESULT_COMMIT
279+ ]);
280+ const liveOutcomes = deliverPhoneMessages(target, liveMessages);
281+ assert.deepEqual(liveOutcomes, [
282+ RECEIVE_RESULT.STAGED,
283+ RECEIVE_RESULT.STAGED,
284+ RECEIVE_RESULT.STAGED,
285+ RECEIVE_RESULT.RESULT_COMMITTED
286+ ]);
287+ assert.equal(
288+ liveOutcomes.filter((outcome) => outcome === RECEIVE_RESULT.RESULT_COMMITTED).length,
289+ 1
290+ );
291+ assert.equal(target.watchView.frames.length, cachedFrameCount + 1);
292+
293+ const liveFrame = target.watchView.frames.at(-1);
294+ assert.equal(liveFrame.snapshot.state, WATCH_STATE.READY);
295+ assert.equal(liveFrame.snapshot.result.requestId, request.REQUEST_ID);
296+ assert.notDeepEqual(liveFrame.presentation, cachedFrame.presentation);
297+});
298+
299+test("an unchanged live signature sends no second result batch or visible frame", (t) => {
300+ const target = harness();
301+ t.after(() => {
302+ target.phone.stop();
303+ target.controller.close();
304+ });
305+ const { cachedFrame, request } = beginCachedConsultation(target);
306+ const unchangedLive = contracts.copyDepartureResult(
307+ target.cachedResult,
308+ request.REQUEST_ID
309+ );
310+ unchangedLive.sourceUpdatedAt += 1;
311+ unchangedLive.departures[0].aimedAt += 1;
312+ unchangedLive.departures[0].minutes += 1;
313+ unchangedLive.departures[1].minutes += 1;
314+ const frameCount = target.watchView.frames.length;
315+ const sentCount = target.Pebble.sent.length;
316+
317+ target.xhr.instances[0].respond(200, unchangedLive);
318+
319+ assert.deepEqual(takePhoneMessages(target), []);
320+ assert.equal(target.Pebble.sent.length, sentCount);
321+ assert.equal(
322+ target.Pebble.sent.filter((message) => message.MESSAGE_TYPE === T.RESULT_BEGIN).length,
323+ 1
324+ );
325+ assert.equal(target.phone.metrics().successes, 1);
326+ assert.equal(target.watchView.frames.length, frameCount);
327+ assert.strictEqual(target.watchView.frames.at(-1), cachedFrame);
328+ assert.deepEqual(target.watchView.frames.at(-1).presentation, cachedFrame.presentation);
329+});
330+
331+test("same-favorite supersession accepts the ordered mirror and suppresses matching replay", (t) => {
332+ const target = harness({ manualPhoneTransport: true });
333+ t.after(() => {
334+ target.phone.stop();
335+ target.controller.close();
336+ });
337+
338+ target.Pebble.emit("ready");
339+ target.readyDefer.runNext();
340+ const configurationMessages = receiveAndAckPhoneSequence(target, [
341+ T.CONFIG_BEGIN,
342+ T.FAVORITE,
343+ T.CONFIG_COMMIT
344+ ]);
345+ assert.deepEqual(
346+ configurationMessages.map(({ received }) => received),
347+ [
348+ RECEIVE_RESULT.STAGED,
349+ RECEIVE_RESULT.STAGED,
350+ RECEIVE_RESULT.CONFIG_COMMITTED
351+ ]
352+ );
353+
354+ const firstRequest = forwardWatchRequest(target);
355+ assert.equal(target.xhr.instances.length, 1);
356+ const firstCachedMessages = receiveAndAckPhoneSequence(target, [
357+ T.RESULT_BEGIN,
358+ T.DEPARTURE,
359+ T.DEPARTURE,
360+ T.RESULT_COMMIT
361+ ]);
362+ assert.deepEqual(
363+ firstCachedMessages.map(({ received }) => received),
364+ [
365+ RECEIVE_RESULT.STAGED,
366+ RECEIVE_RESULT.STAGED,
367+ RECEIVE_RESULT.STAGED,
368+ RECEIVE_RESULT.RESULT_COMMITTED
369+ ]
370+ );
371+ assert.equal(
372+ target.watchView.frames.at(-1).snapshot.result.requestId,
373+ firstRequest.REQUEST_ID
374+ );
375+ assert.equal(target.defer.pending.length, 1);
376+
377+ target.controller.onButton("select");
378+ const supersedingRequest = forwardWatchRequest(target);
379+ assert.notEqual(supersedingRequest.REQUEST_ID, firstRequest.REQUEST_ID);
380+ assert.equal(supersedingRequest.FAVORITE_ID, firstRequest.FAVORITE_ID);
381+ assert.equal(target.xhr.instances.length, 1);
382+
383+ advancePhoneQueue(target);
384+ const orderedMirror = receiveAndAckPhoneMessage(target);
385+ assert.equal(orderedMirror.message.MESSAGE_TYPE, T.REQUEST);
386+ assert.equal(orderedMirror.message.REQUEST_ID, firstRequest.REQUEST_ID);
387+ assert.equal(orderedMirror.received, RECEIVE_RESULT.STAGED);
388+
389+ advancePhoneQueue(target);
390+ const reboundMessages = receiveAndAckPhoneSequence(target, [
391+ T.RESULT_BEGIN,
392+ T.DEPARTURE,
393+ T.DEPARTURE,
394+ T.RESULT_COMMIT,
395+ T.REQUEST
396+ ]);
397+ assert.deepEqual(
398+ reboundMessages.map(({ received }) => received),
399+ [
400+ RECEIVE_RESULT.STAGED,
401+ RECEIVE_RESULT.STAGED,
402+ RECEIVE_RESULT.STAGED,
403+ RECEIVE_RESULT.RESULT_COMMITTED,
404+ RECEIVE_RESULT.STAGED
405+ ]
406+ );
407+ reboundMessages.forEach(({ message }) => {
408+ assert.equal(message.REQUEST_ID, supersedingRequest.REQUEST_ID);
409+ });
410+ assert.equal(
411+ target.watchView.frames.at(-1).snapshot.result.requestId,
412+ supersedingRequest.REQUEST_ID
413+ );
414+
415+ const sentBeforeMatchingRequest = target.Pebble.sent.length;
416+ target.controller.onButton("select");
417+ const latestRequest = forwardWatchRequest(target);
418+ assert.notEqual(latestRequest.REQUEST_ID, supersedingRequest.REQUEST_ID);
419+ assert.equal(target.xhr.instances.length, 1);
420+ assert.equal(target.Pebble.sent.length, sentBeforeMatchingRequest);
421+ assert.equal(target.Pebble.pending.length, 0);
422+
423+ const changedLive = contracts.copyDepartureResult(
424+ fixture.result,
425+ firstRequest.REQUEST_ID
426+ );
427+ changedLive.fetchedAt = Math.floor(target.clock.now() / 1000);
428+ changedLive.sourceUpdatedAt = changedLive.fetchedAt - 1;
429+ changedLive.departures[0].expectedAt += 60;
430+ changedLive.departures[0].minutes += 1;
431+ target.xhr.instances[0].respond(200, changedLive);
432+
433+ const liveMessages = receiveAndAckPhoneSequence(target, [
434+ T.RESULT_BEGIN,
435+ T.DEPARTURE,
436+ T.DEPARTURE,
437+ T.RESULT_COMMIT
438+ ]);
439+ assert.deepEqual(
440+ liveMessages.map(({ received }) => received),
441+ [
442+ RECEIVE_RESULT.STAGED,
443+ RECEIVE_RESULT.STAGED,
444+ RECEIVE_RESULT.STAGED,
445+ RECEIVE_RESULT.RESULT_COMMITTED
446+ ]
447+ );
448+ liveMessages.forEach(({ message }) => {
449+ assert.equal(message.REQUEST_ID, latestRequest.REQUEST_ID);
450+ });
451+ const liveFrame = target.watchView.frames.at(-1);
452+ assert.equal(liveFrame.snapshot.state, WATCH_STATE.READY);
453+ assert.equal(liveFrame.snapshot.result.requestId, latestRequest.REQUEST_ID);
454+ assert.equal(
455+ liveFrame.snapshot.result.departures[0].expectedAt,
456+ changedLive.departures[0].expectedAt
457+ );
458+ assert.equal(
459+ target.Pebble.sent.filter((message) => message.MESSAGE_TYPE === T.RESULT_BEGIN).length,
460+ 3
461+ );
462+});
463+
464+test("a NACKed cached-to-live mirror makes that flight cache-only until a later request", (t) => {
465+ const target = harness({ manualPhoneTransport: true });
466+ t.after(() => {
467+ target.phone.stop();
468+ target.controller.close();
469+ });
470+
471+ target.Pebble.emit("ready");
472+ target.readyDefer.runNext();
473+ receiveAndAckPhoneSequence(target, [
474+ T.CONFIG_BEGIN,
475+ T.FAVORITE,
476+ T.CONFIG_COMMIT
477+ ]);
478+ const firstRequest = forwardWatchRequest(target);
479+ const cachedMessages = receiveAndAckPhoneSequence(target, [
480+ T.RESULT_BEGIN,
481+ T.DEPARTURE,
482+ T.DEPARTURE,
483+ T.RESULT_COMMIT
484+ ]);
485+ assert.equal(cachedMessages.at(-1).received, RECEIVE_RESULT.RESULT_COMMITTED);
486+ const cachedFrame = target.watchView.frames.at(-1);
487+
488+ advancePhoneQueue(target);
489+ const failedMirror = nackPhoneMessage(target);
490+ assert.equal(failedMirror.MESSAGE_TYPE, T.REQUEST);
491+ assert.equal(failedMirror.REQUEST_ID, firstRequest.REQUEST_ID);
492+ assert.equal(target.defer.pending.length, 0);
493+
494+ const changedLive = contracts.copyDepartureResult(
495+ fixture.result,
496+ firstRequest.REQUEST_ID
497+ );
498+ changedLive.fetchedAt = Math.floor(target.clock.now() / 1000);
499+ changedLive.sourceUpdatedAt = changedLive.fetchedAt - 1;
500+ changedLive.departures[0].expectedAt += 60;
501+ changedLive.departures[0].minutes += 1;
502+ const sentBeforeCompletion = target.Pebble.sent.length;
503+
504+ target.xhr.instances[0].respond(200, changedLive);
505+ assert.equal(target.Pebble.sent.length, sentBeforeCompletion);
506+ assert.strictEqual(target.watchView.frames.at(-1), cachedFrame);
507+
508+ target.controller.onButton("select");
509+ const latestRequest = forwardWatchRequest(target);
510+ assert.notEqual(latestRequest.REQUEST_ID, firstRequest.REQUEST_ID);
511+ assert.equal(target.xhr.instances.length, 1);
512+
513+ const reboundMessages = receiveAndAckPhoneSequence(target, [
514+ T.RESULT_BEGIN,
515+ T.DEPARTURE,
516+ T.DEPARTURE,
517+ T.RESULT_COMMIT
518+ ]);
519+ assert.deepEqual(
520+ reboundMessages.map(({ received }) => received),
521+ [
522+ RECEIVE_RESULT.STAGED,
523+ RECEIVE_RESULT.STAGED,
524+ RECEIVE_RESULT.STAGED,
525+ RECEIVE_RESULT.RESULT_COMMITTED
526+ ]
527+ );
528+ reboundMessages.forEach(({ message }) => {
529+ assert.equal(message.REQUEST_ID, latestRequest.REQUEST_ID);
530+ });
531+ const reboundFrame = target.watchView.frames.at(-1);
532+ assert.equal(reboundFrame.snapshot.state, WATCH_STATE.READY);
533+ assert.equal(reboundFrame.snapshot.result.requestId, latestRequest.REQUEST_ID);
534+ assert.equal(
535+ reboundFrame.snapshot.result.departures[0].expectedAt,
536+ changedLive.departures[0].expectedAt
537+ );
102538 });
@@ -2,12 +2,19 @@ import test from "node:test";
2 import assert from "node:assert/strict";2 import assert from "node:assert/strict";
3 import { createRequire } from "node:module";3 import { createRequire } from "node:module";
4 import { createController } from "../src/embeddedjs/controller.js";4 import { createController } from "../src/embeddedjs/controller.js";
5-import WatchModel from "../src/embeddedjs/model.js";5+import WatchModel, { WATCH_STATE } from "../src/embeddedjs/model.js";
6+import { createPresentation } from "../src/embeddedjs/presentation.js";
7+import { RECEIVE_RESULT } from "../src/embeddedjs/protocol.js";
6 8
7 const require = createRequire(import.meta.url);9 const require = createRequire(import.meta.url);
8 const companion = require("../../companion/src/index.js");10 const companion = require("../../companion/src/index.js");
9-const fixture = require("../../../fixtures/departures/foundation.json");
10 const fakes = require("../../companion/test/fakes.js");11 const fakes = require("../../companion/test/fakes.js");
12+const fixture = require("../../../fixtures/departures/foundation.json");
13+
14+const contracts = companion.contracts;
15+const codec = companion.codec;
16+const T = contracts.MESSAGE_TYPE;
17+const TEST_KEY = "test-personal-prim-key";
11 18
12 class WatchQueue {19 class WatchQueue {
13 constructor() {20 constructor() {
@@ -24,11 +31,15 @@ class WatchQueue {
24 31
25 class WatchView {32 class WatchView {
26 constructor() {33 constructor() {
27- this.snapshots = [];34+ this.frames = [];
35+ this.screenInfo = { round: false, hour12: false };
28 }36 }
29 37
30 render(snapshot) {38 render(snapshot) {
31- this.snapshots.push(snapshot);39+ this.frames.push({
40+ snapshot,
41+ presentation: createPresentation(snapshot, this.screenInfo)
42+ });
32 }43 }
33 44
34 close() {}45 close() {}
@@ -38,65 +49,490 @@ function aliasMap(dictionary) {
38 return new Map(Object.entries(dictionary));49 return new Map(Object.entries(dictionary));
39 }50 }
40 51
41-test("foundation fixture completes the PKJS to Alloy round trip without a key or XHR", () => {52+function messageTypes(messages) {
42- const Pebble = new fakes.FakePebble(true);53+ return messages.map((message) => message.MESSAGE_TYPE);
54+}
55+
56+function expiredResult(clock) {
57+ const result = contracts.copyDepartureResult(fixture.result, "cached-seed");
58+ result.fetchedAt = Math.floor(clock.now() / 1000) - contracts.CACHE_FRESH_SECONDS;
59+ result.sourceUpdatedAt = result.fetchedAt - 10;
60+ return result;
61+}
62+
63+function configuredStorage(result, storedAt) {
43 const storage = new fakes.FakeStorage();64 const storage = new fakes.FakeStorage();
44- const phoneClock = new fakes.FakeClock(fixture.result.fetchedAt * 1000);65+ assert.equal(companion.configuration.saveConfiguration(storage, {
66+ schemaVersion: contracts.SCHEMA_VERSION,
67+ favorites: [fixture.favorite],
68+ primApiKey: TEST_KEY,
69+ keyStatus: contracts.KEY_STATUS.CONFIGURED
70+ }), true);
71+ assert.equal(companion.configuration.saveResults(storage, {
72+ schemaVersion: contracts.SCHEMA_VERSION,
73+ results: [{
74+ favoriteId: result.favoriteId,
75+ storedAt,
76+ result
77+ }]
78+ }), true);
79+ storage.writes.length = 0;
80+ return storage;
81+}
82+
83+function harness(options = {}) {
84+ const clock = new fakes.FakeClock(fixture.result.fetchedAt * 1000);
85+ const cachedResult = expiredResult(clock);
86+ const storage = configuredStorage(cachedResult, clock.now());
87+ const manualPhoneTransport = options.manualPhoneTransport === true;
88+ const Pebble = new fakes.FakePebble(!manualPhoneTransport);
89+ const defer = fakes.createDefer(!manualPhoneTransport);
90+ const readyDefer = fakes.createDefer(!manualPhoneTransport);
45 const xhr = fakes.createXHRFactory();91 const xhr = fakes.createXHRFactory();
46- companion.createCompanion({92+ const phone = companion.createCompanion({
47 Pebble,93 Pebble,
48 storage,94 storage,
49 XHR: xhr.XHR,95 XHR: xhr.XHR,
50- clock: phoneClock,96+ clock,
51- defer: fakes.createDefer(true),97+ defer,
52- backendUrl: "",98+ readyDefer,
53- configurationUrl: "",99+ backendUrl: "https://backend.example.test/departures",
54- fixture100+ configurationUrl: "https://config.example.test/index.html"
55 });101 });
56-
57 const watchQueue = new WatchQueue();102 const watchQueue = new WatchQueue();
58 const watchView = new WatchView();103 const watchView = new WatchView();
104+ const scheduler = {
105+ set(callback, delayMs) {
106+ return clock.setTimeout(callback, delayMs);
107+ },
108+ clear(timerId) {
109+ clock.clearTimeout(timerId);
110+ }
111+ };
59 const controller = createController({112 const controller = createController({
60- clock: { now: () => phoneClock.now() },113+ clock,
61 queue: watchQueue,114 queue: watchQueue,
62- view: watchView,115+ scheduler,
63- model: new WatchModel()116+ model: new WatchModel(),
117+ view: watchView
64 }).start();118 }).start();
119+ return {
120+ cachedResult,
121+ clock,
122+ controller,
123+ defer,
124+ readyDefer,
125+ Pebble,
126+ phone,
127+ phoneCursor: 0,
128+ storage,
129+ watchCursor: 0,
130+ watchQueue,
131+ watchView,
132+ xhr
133+ };
134+}
135+
136+function takePhoneMessages(target) {
137+ const messages = target.Pebble.sent.slice(target.phoneCursor);
138+ target.phoneCursor = target.Pebble.sent.length;
139+ return messages;
140+}
141+
142+function deliverPhoneMessages(target, messages) {
143+ return messages.map((message) => target.controller.onReadable(aliasMap(message)));
144+}
145+
146+function pendingPhoneMessage(target) {
147+ assert.equal(target.Pebble.pending.length, 1);
148+ const message = target.Pebble.sent[target.phoneCursor];
149+ assert.ok(message);
150+ target.phoneCursor += 1;
151+ return message;
152+}
153+
154+function receiveAndAckPhoneMessage(target) {
155+ const message = pendingPhoneMessage(target);
156+ const received = target.controller.onReadable(aliasMap(message));
157+ target.Pebble.ack();
158+ return { message, received };
159+}
160+
161+function nackPhoneMessage(target) {
162+ const message = pendingPhoneMessage(target);
163+ target.Pebble.fail();
164+ return message;
165+}
166+
167+function advancePhoneQueue(target) {
168+ assert.equal(target.defer.pending.length, 1);
169+ target.defer.runNext();
170+ assert.equal(target.Pebble.pending.length, 1);
171+}
172+
173+function receiveAndAckPhoneSequence(target, types) {
174+ return types.map((type, index) => {
175+ if (index > 0) advancePhoneQueue(target);
176+ const settled = receiveAndAckPhoneMessage(target);
177+ assert.equal(settled.message.MESSAGE_TYPE, type);
178+ return settled;
179+ });
180+}
181+
182+function forwardWatchRequest(target) {
183+ const message = target.watchQueue.messages[target.watchCursor];
184+ assert.ok(message instanceof Map);
185+ target.watchCursor += 1;
186+ const payload = Object.fromEntries(message);
187+ target.Pebble.emit("appmessage", { payload });
188+ return payload;
189+}
65 190
66- Pebble.emit("ready");191+function beginCachedConsultation(target) {
67- const configurationMessages = Pebble.sent.slice();192+ target.Pebble.emit("ready");
68- assert.deepEqual(configurationMessages.map((message) => message.MESSAGE_TYPE), [193+ const configurationMessages = takePhoneMessages(target);
69- companion.contracts.MESSAGE_TYPE.CONFIG_BEGIN,194+ assert.deepEqual(messageTypes(configurationMessages), [
70- companion.contracts.MESSAGE_TYPE.FAVORITE,195+ T.CONFIG_BEGIN,
71- companion.contracts.MESSAGE_TYPE.CONFIG_COMMIT196+ T.FAVORITE,
197+ T.CONFIG_COMMIT
72 ]);198 ]);
73- assert.equal(configurationMessages[0].KEY_STATUS, companion.contracts.KEY_STATUS.CONFIGURED);199+ assert.deepEqual(deliverPhoneMessages(target, configurationMessages), [
74- assert.equal(configurationMessages[0].DISPLAY_NAME, "en");200+ RECEIVE_RESULT.STAGED,
75- configurationMessages.forEach((message) => controller.onReadable(aliasMap(message)));201+ RECEIVE_RESULT.STAGED,
76-202+ RECEIVE_RESULT.CONFIG_COMMITTED
77- assert.equal(watchQueue.messages.length, 1);203+ ]);
78- const request = Object.fromEntries(watchQueue.messages[0]);204+
79- assert.equal(request.REQUEST_TRIGGER, companion.contracts.REQUEST_TRIGGER.APP_OPEN);205+ assert.equal(target.watchQueue.messages.length, 1);
80- assert.equal(Object.keys(request).some((key) => /^\d+$/u.test(key)), false);206+ const request = forwardWatchRequest(target);
81-207+ assert.equal(request.MESSAGE_TYPE, T.REQUEST);
82- const resultStart = Pebble.sent.length;208+ assert.equal(request.FAVORITE_ID, fixture.favorite.id);
83- Pebble.emit("appmessage", { payload: request });209+ assert.equal(request.REQUEST_TRIGGER, contracts.REQUEST_TRIGGER.APP_OPEN);
84- const resultMessages = Pebble.sent.slice(resultStart);210+ assert.equal(target.xhr.instances.length, 1);
85- assert.deepEqual(resultMessages.map((message) => message.MESSAGE_TYPE), [211+
86- companion.contracts.MESSAGE_TYPE.RESULT_BEGIN,212+ const cachedMessages = takePhoneMessages(target);
87- companion.contracts.MESSAGE_TYPE.DEPARTURE,213+ assert.deepEqual(messageTypes(cachedMessages), [
88- companion.contracts.MESSAGE_TYPE.DEPARTURE,214+ T.RESULT_BEGIN,
89- companion.contracts.MESSAGE_TYPE.RESULT_COMMIT215+ T.DEPARTURE,
216+ T.DEPARTURE,
217+ T.RESULT_COMMIT,
218+ T.REQUEST
219+ ]);
220+ const rendersBeforeCache = target.watchView.frames.length;
221+ assert.deepEqual(deliverPhoneMessages(target, cachedMessages), [
222+ RECEIVE_RESULT.STAGED,
223+ RECEIVE_RESULT.STAGED,
224+ RECEIVE_RESULT.STAGED,
225+ RECEIVE_RESULT.RESULT_COMMITTED,
226+ RECEIVE_RESULT.STAGED
227+ ]);
228+ assert.equal(target.watchView.frames.length, rendersBeforeCache + 1);
229+
230+ const cachedFrame = target.watchView.frames.at(-1);
231+ assert.equal(cachedFrame.snapshot.state, WATCH_STATE.STALE);
232+ assert.equal(cachedFrame.snapshot.result.requestId, request.REQUEST_ID);
233+ assert.equal(cachedFrame.snapshot.result.favoriteId, fixture.favorite.id);
234+ assert.equal(target.xhr.instances[0].status, 0);
235+ assert.equal(target.xhr.instances[0].responseText, "");
236+ return { cachedFrame, request };
237+}
238+
239+test("expired cache commits before one changed live result replaces it atomically", (t) => {
240+ const target = harness();
241+ t.after(() => {
242+ target.phone.stop();
243+ target.controller.close();
244+ });
245+ const { cachedFrame, request } = beginCachedConsultation(target);
246+ const changedLive = contracts.copyDepartureResult(fixture.result, request.REQUEST_ID);
247+ changedLive.fetchedAt = Math.floor(target.clock.now() / 1000);
248+ changedLive.sourceUpdatedAt = changedLive.fetchedAt - 1;
249+ changedLive.departures[0].expectedAt += 60;
250+ changedLive.departures[0].minutes += 1;
251+
252+ const interrupted = codec.encodeResult(changedLive);
253+ const mismatchedDeparture = {
254+ ...interrupted[2],
255+ REQUEST_ID: "mismatched-live"
256+ };
257+ const cachedFrameCount = target.watchView.frames.length;
258+ assert.deepEqual([
259+ target.controller.onReadable(aliasMap(interrupted[0])),
260+ target.controller.onReadable(aliasMap(interrupted[1])),
261+ target.controller.onReadable(aliasMap(mismatchedDeparture)),
262+ target.controller.onReadable(aliasMap(interrupted.at(-1)))
263+ ], [
264+ RECEIVE_RESULT.STAGED,
265+ RECEIVE_RESULT.STAGED,
266+ RECEIVE_RESULT.REJECTED,
267+ RECEIVE_RESULT.REJECTED
90 ]);268 ]);
91- resultMessages.forEach((message) => controller.onReadable(aliasMap(message)));269+ assert.equal(target.watchView.frames.length, cachedFrameCount);
92-270+ assert.deepEqual(target.watchView.frames.at(-1).presentation, cachedFrame.presentation);
93- const finalSnapshot = watchView.snapshots.at(-1);271+
94- assert.equal(finalSnapshot.favorite.id, fixture.favorite.id);272+ target.xhr.instances[0].respond(200, changedLive);
95- assert.equal(finalSnapshot.result.favoriteId, fixture.result.favoriteId);273+ const liveMessages = takePhoneMessages(target);
96- assert.equal(finalSnapshot.result.departures.length, 2);274+ assert.deepEqual(messageTypes(liveMessages), [
97- assert.equal(finalSnapshot.result.departures[0].minutes, fixture.result.departures[0].minutes);275+ T.RESULT_BEGIN,
98- assert.equal(finalSnapshot.sendFailed, false);276+ T.DEPARTURE,
99- assert.equal(xhr.instances.length, 0);277+ T.DEPARTURE,
100- assert.equal(storage.writes.length, 0);278+ T.RESULT_COMMIT
101- controller.close();279+ ]);
280+ const liveOutcomes = deliverPhoneMessages(target, liveMessages);
281+ assert.deepEqual(liveOutcomes, [
282+ RECEIVE_RESULT.STAGED,
283+ RECEIVE_RESULT.STAGED,
284+ RECEIVE_RESULT.STAGED,
285+ RECEIVE_RESULT.RESULT_COMMITTED
286+ ]);
287+ assert.equal(
288+ liveOutcomes.filter((outcome) => outcome === RECEIVE_RESULT.RESULT_COMMITTED).length,
289+ 1
290+ );
291+ assert.equal(target.watchView.frames.length, cachedFrameCount + 1);
292+
293+ const liveFrame = target.watchView.frames.at(-1);
294+ assert.equal(liveFrame.snapshot.state, WATCH_STATE.READY);
295+ assert.equal(liveFrame.snapshot.result.requestId, request.REQUEST_ID);
296+ assert.notDeepEqual(liveFrame.presentation, cachedFrame.presentation);
297+});
298+
299+test("an unchanged live signature sends no second result batch or visible frame", (t) => {
300+ const target = harness();
301+ t.after(() => {
302+ target.phone.stop();
303+ target.controller.close();
304+ });
305+ const { cachedFrame, request } = beginCachedConsultation(target);
306+ const unchangedLive = contracts.copyDepartureResult(
307+ target.cachedResult,
308+ request.REQUEST_ID
309+ );
310+ unchangedLive.sourceUpdatedAt += 1;
311+ unchangedLive.departures[0].aimedAt += 1;
312+ unchangedLive.departures[0].minutes += 1;
313+ unchangedLive.departures[1].minutes += 1;
314+ const frameCount = target.watchView.frames.length;
315+ const sentCount = target.Pebble.sent.length;
316+
317+ target.xhr.instances[0].respond(200, unchangedLive);
318+
319+ assert.deepEqual(takePhoneMessages(target), []);
320+ assert.equal(target.Pebble.sent.length, sentCount);
321+ assert.equal(
322+ target.Pebble.sent.filter((message) => message.MESSAGE_TYPE === T.RESULT_BEGIN).length,
323+ 1
324+ );
325+ assert.equal(target.phone.metrics().successes, 1);
326+ assert.equal(target.watchView.frames.length, frameCount);
327+ assert.strictEqual(target.watchView.frames.at(-1), cachedFrame);
328+ assert.deepEqual(target.watchView.frames.at(-1).presentation, cachedFrame.presentation);
329+});
330+
331+test("same-favorite supersession accepts the ordered mirror and suppresses matching replay", (t) => {
332+ const target = harness({ manualPhoneTransport: true });
333+ t.after(() => {
334+ target.phone.stop();
335+ target.controller.close();
336+ });
337+
338+ target.Pebble.emit("ready");
339+ target.readyDefer.runNext();
340+ const configurationMessages = receiveAndAckPhoneSequence(target, [
341+ T.CONFIG_BEGIN,
342+ T.FAVORITE,
343+ T.CONFIG_COMMIT
344+ ]);
345+ assert.deepEqual(
346+ configurationMessages.map(({ received }) => received),
347+ [
348+ RECEIVE_RESULT.STAGED,
349+ RECEIVE_RESULT.STAGED,
350+ RECEIVE_RESULT.CONFIG_COMMITTED
351+ ]
352+ );
353+
354+ const firstRequest = forwardWatchRequest(target);
355+ assert.equal(target.xhr.instances.length, 1);
356+ const firstCachedMessages = receiveAndAckPhoneSequence(target, [
357+ T.RESULT_BEGIN,
358+ T.DEPARTURE,
359+ T.DEPARTURE,
360+ T.RESULT_COMMIT
361+ ]);
362+ assert.deepEqual(
363+ firstCachedMessages.map(({ received }) => received),
364+ [
365+ RECEIVE_RESULT.STAGED,
366+ RECEIVE_RESULT.STAGED,
367+ RECEIVE_RESULT.STAGED,
368+ RECEIVE_RESULT.RESULT_COMMITTED
369+ ]
370+ );
371+ assert.equal(
372+ target.watchView.frames.at(-1).snapshot.result.requestId,
373+ firstRequest.REQUEST_ID
374+ );
375+ assert.equal(target.defer.pending.length, 1);
376+
377+ target.controller.onButton("select");
378+ const supersedingRequest = forwardWatchRequest(target);
379+ assert.notEqual(supersedingRequest.REQUEST_ID, firstRequest.REQUEST_ID);
380+ assert.equal(supersedingRequest.FAVORITE_ID, firstRequest.FAVORITE_ID);
381+ assert.equal(target.xhr.instances.length, 1);
382+
383+ advancePhoneQueue(target);
384+ const orderedMirror = receiveAndAckPhoneMessage(target);
385+ assert.equal(orderedMirror.message.MESSAGE_TYPE, T.REQUEST);
386+ assert.equal(orderedMirror.message.REQUEST_ID, firstRequest.REQUEST_ID);
387+ assert.equal(orderedMirror.received, RECEIVE_RESULT.STAGED);
388+
389+ advancePhoneQueue(target);
390+ const reboundMessages = receiveAndAckPhoneSequence(target, [
391+ T.RESULT_BEGIN,
392+ T.DEPARTURE,
393+ T.DEPARTURE,
394+ T.RESULT_COMMIT,
395+ T.REQUEST
396+ ]);
397+ assert.deepEqual(
398+ reboundMessages.map(({ received }) => received),
399+ [
400+ RECEIVE_RESULT.STAGED,
401+ RECEIVE_RESULT.STAGED,
402+ RECEIVE_RESULT.STAGED,
403+ RECEIVE_RESULT.RESULT_COMMITTED,
404+ RECEIVE_RESULT.STAGED
405+ ]
406+ );
407+ reboundMessages.forEach(({ message }) => {
408+ assert.equal(message.REQUEST_ID, supersedingRequest.REQUEST_ID);
409+ });
410+ assert.equal(
411+ target.watchView.frames.at(-1).snapshot.result.requestId,
412+ supersedingRequest.REQUEST_ID
413+ );
414+
415+ const sentBeforeMatchingRequest = target.Pebble.sent.length;
416+ target.controller.onButton("select");
417+ const latestRequest = forwardWatchRequest(target);
418+ assert.notEqual(latestRequest.REQUEST_ID, supersedingRequest.REQUEST_ID);
419+ assert.equal(target.xhr.instances.length, 1);
420+ assert.equal(target.Pebble.sent.length, sentBeforeMatchingRequest);
421+ assert.equal(target.Pebble.pending.length, 0);
422+
423+ const changedLive = contracts.copyDepartureResult(
424+ fixture.result,
425+ firstRequest.REQUEST_ID
426+ );
427+ changedLive.fetchedAt = Math.floor(target.clock.now() / 1000);
428+ changedLive.sourceUpdatedAt = changedLive.fetchedAt - 1;
429+ changedLive.departures[0].expectedAt += 60;
430+ changedLive.departures[0].minutes += 1;
431+ target.xhr.instances[0].respond(200, changedLive);
432+
433+ const liveMessages = receiveAndAckPhoneSequence(target, [
434+ T.RESULT_BEGIN,
435+ T.DEPARTURE,
436+ T.DEPARTURE,
437+ T.RESULT_COMMIT
438+ ]);
439+ assert.deepEqual(
440+ liveMessages.map(({ received }) => received),
441+ [
442+ RECEIVE_RESULT.STAGED,
443+ RECEIVE_RESULT.STAGED,
444+ RECEIVE_RESULT.STAGED,
445+ RECEIVE_RESULT.RESULT_COMMITTED
446+ ]
447+ );
448+ liveMessages.forEach(({ message }) => {
449+ assert.equal(message.REQUEST_ID, latestRequest.REQUEST_ID);
450+ });
451+ const liveFrame = target.watchView.frames.at(-1);
452+ assert.equal(liveFrame.snapshot.state, WATCH_STATE.READY);
453+ assert.equal(liveFrame.snapshot.result.requestId, latestRequest.REQUEST_ID);
454+ assert.equal(
455+ liveFrame.snapshot.result.departures[0].expectedAt,
456+ changedLive.departures[0].expectedAt
457+ );
458+ assert.equal(
459+ target.Pebble.sent.filter((message) => message.MESSAGE_TYPE === T.RESULT_BEGIN).length,
460+ 3
461+ );
462+});
463+
464+test("a NACKed cached-to-live mirror makes that flight cache-only until a later request", (t) => {
465+ const target = harness({ manualPhoneTransport: true });
466+ t.after(() => {
467+ target.phone.stop();
468+ target.controller.close();
469+ });
470+
471+ target.Pebble.emit("ready");
472+ target.readyDefer.runNext();
473+ receiveAndAckPhoneSequence(target, [
474+ T.CONFIG_BEGIN,
475+ T.FAVORITE,
476+ T.CONFIG_COMMIT
477+ ]);
478+ const firstRequest = forwardWatchRequest(target);
479+ const cachedMessages = receiveAndAckPhoneSequence(target, [
480+ T.RESULT_BEGIN,
481+ T.DEPARTURE,
482+ T.DEPARTURE,
483+ T.RESULT_COMMIT
484+ ]);
485+ assert.equal(cachedMessages.at(-1).received, RECEIVE_RESULT.RESULT_COMMITTED);
486+ const cachedFrame = target.watchView.frames.at(-1);
487+
488+ advancePhoneQueue(target);
489+ const failedMirror = nackPhoneMessage(target);
490+ assert.equal(failedMirror.MESSAGE_TYPE, T.REQUEST);
491+ assert.equal(failedMirror.REQUEST_ID, firstRequest.REQUEST_ID);
492+ assert.equal(target.defer.pending.length, 0);
493+
494+ const changedLive = contracts.copyDepartureResult(
495+ fixture.result,
496+ firstRequest.REQUEST_ID
497+ );
498+ changedLive.fetchedAt = Math.floor(target.clock.now() / 1000);
499+ changedLive.sourceUpdatedAt = changedLive.fetchedAt - 1;
500+ changedLive.departures[0].expectedAt += 60;
501+ changedLive.departures[0].minutes += 1;
502+ const sentBeforeCompletion = target.Pebble.sent.length;
503+
504+ target.xhr.instances[0].respond(200, changedLive);
505+ assert.equal(target.Pebble.sent.length, sentBeforeCompletion);
506+ assert.strictEqual(target.watchView.frames.at(-1), cachedFrame);
507+
508+ target.controller.onButton("select");
509+ const latestRequest = forwardWatchRequest(target);
510+ assert.notEqual(latestRequest.REQUEST_ID, firstRequest.REQUEST_ID);
511+ assert.equal(target.xhr.instances.length, 1);
512+
513+ const reboundMessages = receiveAndAckPhoneSequence(target, [
514+ T.RESULT_BEGIN,
515+ T.DEPARTURE,
516+ T.DEPARTURE,
517+ T.RESULT_COMMIT
518+ ]);
519+ assert.deepEqual(
520+ reboundMessages.map(({ received }) => received),
521+ [
522+ RECEIVE_RESULT.STAGED,
523+ RECEIVE_RESULT.STAGED,
524+ RECEIVE_RESULT.STAGED,
525+ RECEIVE_RESULT.RESULT_COMMITTED
526+ ]
527+ );
528+ reboundMessages.forEach(({ message }) => {
529+ assert.equal(message.REQUEST_ID, latestRequest.REQUEST_ID);
530+ });
531+ const reboundFrame = target.watchView.frames.at(-1);
532+ assert.equal(reboundFrame.snapshot.state, WATCH_STATE.READY);
533+ assert.equal(reboundFrame.snapshot.result.requestId, latestRequest.REQUEST_ID);
534+ assert.equal(
535+ reboundFrame.snapshot.result.departures[0].expectedAt,
536+ changedLive.departures[0].expectedAt
537+ );
102 });538 });
modified packages/watch/test/localization.test.js +62 -21
@@ -1,43 +1,84 @@
11 import test from "node:test";
22 import assert from "node:assert/strict";
33 import {
4- COPY,
54 copy,
65 formatMinutes,
76 normalizeLanguage
87 } from "../src/embeddedjs/localization.js";
98
10-test("foundation fixture copy has complete French and English tables", () => {
11- assert.deepEqual(Object.keys(COPY.fr).sort(), Object.keys(COPY.en).sort());
12- Object.keys(COPY.en).forEach((key) => {
13- assert.equal(typeof COPY.en[key], "string");
14- assert.equal(COPY.en[key].length > 0, true);
15- assert.equal(typeof COPY.fr[key], "string");
16- assert.equal(COPY.fr[key].length > 0, true);
17- });
9+const EXPECTED_COPY = {
10+ en: {
11+ unconfigured: "Open phone options\nAdd a PRIM key and favorite",
12+ loading: "Loading departures…",
13+ apiKeyInvalid: "PRIM key rejected\nOpen phone options",
14+ favoriteUnavailable: "Favorite unavailable\nOpen phone options",
15+ rateLimited: "Too many requests\nTry again later",
16+ departuresUnavailable: "Departures unavailable\nSelect to retry",
17+ noDepartures: "No departures",
18+ delayed: "Delayed",
19+ cancelled: "Cancelled",
20+ then: "then",
21+ freshRealtime: "Live",
22+ freshScheduled: "Scheduled",
23+ freshMixed: "Live + schedule",
24+ freshStale: "Out of date",
25+ updated: "Updated",
26+ now: "Now",
27+ departed: "Departed"
28+ },
29+ fr: {
30+ unconfigured: "Ouvrez les options téléphone\nAjoutez une clé PRIM et un favori",
31+ loading: "Chargement des départs…",
32+ apiKeyInvalid: "Clé PRIM refusée\nOuvrez les options téléphone",
33+ favoriteUnavailable: "Favori indisponible\nOuvrez les options téléphone",
34+ rateLimited: "Trop de demandes\nRéessayez plus tard",
35+ departuresUnavailable: "Départs indisponibles\nSélection pour réessayer",
36+ noDepartures: "Aucun départ",
37+ delayed: "Retardé",
38+ cancelled: "Annulé",
39+ then: "puis",
40+ freshRealtime: "Temps réel",
41+ freshScheduled: "Horaires",
42+ freshMixed: "Temps réel + horaires",
43+ freshStale: "Périmé",
44+ updated: "Mis à jour",
45+ now: "À quai",
46+ departed: "Parti"
47+ }
48+};
49+
50+test("consultation copy returns every exact authored French and English string", () => {
51+ const ids = Object.keys(EXPECTED_COPY.en).sort();
52+ assert.deepEqual(Object.keys(EXPECTED_COPY.fr).sort(), ids);
53+ for (const language of ["en", "fr"]) {
54+ for (const id of ids) {
55+ assert.equal(
56+ copy(language, id),
57+ EXPECTED_COPY[language][id],
58+ language + ":" + id
59+ );
60+ }
61+ }
1862 });
1963
20-test("fr language subtags select French and every other input falls back to English", () => {
64+test("French subtags select French and every other input falls back to English", () => {
2165 ["fr", "fr-FR", "fr_FR", "fr-CA", "FR_fr"].forEach((tag) => {
2266 assert.equal(normalizeLanguage(tag), "fr");
67+ assert.equal(copy(tag, "loading"), "Chargement des départs…");
2368 });
2469 [undefined, null, "", "en", "en-US", "de-DE", "french"].forEach((tag) => {
2570 assert.equal(normalizeLanguage(tag), "en");
71+ assert.equal(copy(tag, "loading"), "Loading departures…");
2672 });
27- assert.equal(copy("fr_FR", "waiting"), "En attente des départs enregistrés…");
28- assert.equal(copy("de_DE", "waiting"), "Waiting for recorded departures…");
73+ assert.equal(copy("fr", "not-a-copy-key"), "");
2974 });
3075
31-test("fixture countdown formatting is bilingual and does not translate transport labels", () => {
76+test("countdown copy is bilingual without treating user labels as copy keys", () => {
3277 assert.equal(formatMinutes("en", -1), "Departed");
78+ assert.equal(formatMinutes("fr", -1), "Parti");
79+ assert.equal(formatMinutes("en", 0), "Now");
3380 assert.equal(formatMinutes("fr", 0), "À quai");
3481 assert.equal(formatMinutes("en", 12), "12 min");
35- const labels = {
36- displayName: "Bureau – côté Seine",
37- stopLabel: "Saint-Michel Notre-Dame",
38- lineLabel: "RER B",
39- destinationLabel: "Aéroport Charles-de-Gaulle 2 TGV"
40- };
41- assert.deepEqual({ ...labels }, labels);
42- assert.equal(copy("fr", labels.stopLabel), "");
82+ assert.equal(formatMinutes("fr", 12), "12 min");
83+ assert.equal(copy("fr", "Saint-Michel Notre-Dame"), "");
4384 });
@@ -1,43 +1,84 @@
1 import test from "node:test";1 import test from "node:test";
2 import assert from "node:assert/strict";2 import assert from "node:assert/strict";
3 import {3 import {
4- COPY,
5 copy,4 copy,
6 formatMinutes,5 formatMinutes,
7 normalizeLanguage6 normalizeLanguage
8 } from "../src/embeddedjs/localization.js";7 } from "../src/embeddedjs/localization.js";
9 8
10-test("foundation fixture copy has complete French and English tables", () => {9+const EXPECTED_COPY = {
11- assert.deepEqual(Object.keys(COPY.fr).sort(), Object.keys(COPY.en).sort());10+ en: {
12- Object.keys(COPY.en).forEach((key) => {11+ unconfigured: "Open phone options\nAdd a PRIM key and favorite",
13- assert.equal(typeof COPY.en[key], "string");12+ loading: "Loading departures…",
14- assert.equal(COPY.en[key].length > 0, true);13+ apiKeyInvalid: "PRIM key rejected\nOpen phone options",
15- assert.equal(typeof COPY.fr[key], "string");14+ favoriteUnavailable: "Favorite unavailable\nOpen phone options",
16- assert.equal(COPY.fr[key].length > 0, true);15+ rateLimited: "Too many requests\nTry again later",
17- });16+ departuresUnavailable: "Departures unavailable\nSelect to retry",
17+ noDepartures: "No departures",
18+ delayed: "Delayed",
19+ cancelled: "Cancelled",
20+ then: "then",
21+ freshRealtime: "Live",
22+ freshScheduled: "Scheduled",
23+ freshMixed: "Live + schedule",
24+ freshStale: "Out of date",
25+ updated: "Updated",
26+ now: "Now",
27+ departed: "Departed"
28+ },
29+ fr: {
30+ unconfigured: "Ouvrez les options téléphone\nAjoutez une clé PRIM et un favori",
31+ loading: "Chargement des départs…",
32+ apiKeyInvalid: "Clé PRIM refusée\nOuvrez les options téléphone",
33+ favoriteUnavailable: "Favori indisponible\nOuvrez les options téléphone",
34+ rateLimited: "Trop de demandes\nRéessayez plus tard",
35+ departuresUnavailable: "Départs indisponibles\nSélection pour réessayer",
36+ noDepartures: "Aucun départ",
37+ delayed: "Retardé",
38+ cancelled: "Annulé",
39+ then: "puis",
40+ freshRealtime: "Temps réel",
41+ freshScheduled: "Horaires",
42+ freshMixed: "Temps réel + horaires",
43+ freshStale: "Périmé",
44+ updated: "Mis à jour",
45+ now: "À quai",
46+ departed: "Parti"
47+ }
48+};
49+
50+test("consultation copy returns every exact authored French and English string", () => {
51+ const ids = Object.keys(EXPECTED_COPY.en).sort();
52+ assert.deepEqual(Object.keys(EXPECTED_COPY.fr).sort(), ids);
53+ for (const language of ["en", "fr"]) {
54+ for (const id of ids) {
55+ assert.equal(
56+ copy(language, id),
57+ EXPECTED_COPY[language][id],
58+ language + ":" + id
59+ );
60+ }
61+ }
18 });62 });
19 63
20-test("fr language subtags select French and every other input falls back to English", () => {64+test("French subtags select French and every other input falls back to English", () => {
21 ["fr", "fr-FR", "fr_FR", "fr-CA", "FR_fr"].forEach((tag) => {65 ["fr", "fr-FR", "fr_FR", "fr-CA", "FR_fr"].forEach((tag) => {
22 assert.equal(normalizeLanguage(tag), "fr");66 assert.equal(normalizeLanguage(tag), "fr");
67+ assert.equal(copy(tag, "loading"), "Chargement des départs…");
23 });68 });
24 [undefined, null, "", "en", "en-US", "de-DE", "french"].forEach((tag) => {69 [undefined, null, "", "en", "en-US", "de-DE", "french"].forEach((tag) => {
25 assert.equal(normalizeLanguage(tag), "en");70 assert.equal(normalizeLanguage(tag), "en");
71+ assert.equal(copy(tag, "loading"), "Loading departures…");
26 });72 });
27- assert.equal(copy("fr_FR", "waiting"), "En attente des départs enregistrés…");73+ assert.equal(copy("fr", "not-a-copy-key"), "");
28- assert.equal(copy("de_DE", "waiting"), "Waiting for recorded departures…");
29 });74 });
30 75
31-test("fixture countdown formatting is bilingual and does not translate transport labels", () => {76+test("countdown copy is bilingual without treating user labels as copy keys", () => {
32 assert.equal(formatMinutes("en", -1), "Departed");77 assert.equal(formatMinutes("en", -1), "Departed");
78+ assert.equal(formatMinutes("fr", -1), "Parti");
79+ assert.equal(formatMinutes("en", 0), "Now");
33 assert.equal(formatMinutes("fr", 0), "À quai");80 assert.equal(formatMinutes("fr", 0), "À quai");
34 assert.equal(formatMinutes("en", 12), "12 min");81 assert.equal(formatMinutes("en", 12), "12 min");
35- const labels = {82+ assert.equal(formatMinutes("fr", 12), "12 min");
36- displayName: "Bureau – côté Seine",83+ assert.equal(copy("fr", "Saint-Michel Notre-Dame"), "");
37- stopLabel: "Saint-Michel Notre-Dame",
38- lineLabel: "RER B",
39- destinationLabel: "Aéroport Charles-de-Gaulle 2 TGV"
40- };
41- assert.deepEqual({ ...labels }, labels);
42- assert.equal(copy("fr", labels.stopLabel), "");
43 });84 });
modified packages/watch/test/manifest.test.js +20 -14
@@ -4,9 +4,9 @@ import { createRequire } from "node:module";
44 import { existsSync, readFileSync, readdirSync } from "node:fs";
55 import { fileURLToPath } from "node:url";
66 import {
7- APP_MESSAGE_KEY as WATCH_MESSAGE_KEY,
7+ APP_MESSAGE_INBOX_BYTES as WATCH_MESSAGE_INBOX_BYTES,
88 APP_MESSAGE_KEY_MAP as WATCH_MESSAGE_KEY_MAP,
9- APP_MESSAGE_KEY_ORDER as WATCH_MESSAGE_KEY_ORDER,
9+ APP_MESSAGE_OUTBOX_BYTES as WATCH_MESSAGE_OUTBOX_BYTES,
1010 LANGUAGE as WATCH_LANGUAGE
1111 } from "../src/embeddedjs/contracts.js";
1212 import {
@@ -33,6 +33,7 @@ const expectedModules = [
3333 "./protocol",
3434 "./message-queue",
3535 "./localization",
36+ "./presentation",
3637 "./storage",
3738 "./ui",
3839 "./contracts"
@@ -45,10 +46,10 @@ function moduleSource(name) {
4546 function xsArenaBytes(field) {
4647 const constant = `XS_${field.toUpperCase()}_BYTES`;
4748 const match = mdblSource.match(
48- new RegExp(`^#define ${constant} \\((\\d+)U \\* 1024U\\)$`, "mu")
49+ new RegExp(`^#define ${constant} \\((\\d+)U \\* (512|1024)U\\)$`, "mu")
4950 );
50- assert.notEqual(match, null, `${constant} must be an explicit KiB allocation`);
51- return Number(match[1]) * 1024;
51+ assert.notEqual(match, null, `${constant} must be an explicit allocation`);
52+ return Number(match[1]) * Number(match[2]);
5253 }
5354
5455 test("watch package is one multi-module Alloy app for emery and gabbro", () => {
@@ -70,11 +71,6 @@ test("Alloy bootstrap uses one bounded explicit XS creation record", () => {
7071 const arenaValues = Object.values(arenas);
7172 const arenaTotal = arenaValues.reduce((total, bytes) => total + bytes, 0);
7273
73- assert.deepEqual(arenas, {
74- stack: 4 * 1024,
75- slot: 32 * 1024,
76- chunk: 20 * 1024
77- });
7874 assert.equal(arenaValues.every((bytes) => bytes > 0), true);
7975 assert.equal(arenaTotal, 56 * 1024);
8076 assert.equal(arenaTotal > 32 * 1024, true, "record must exceed the firmware static default");
@@ -115,7 +111,7 @@ test("Waf builds every target before bundling generated PKJS into one package",
115111 assert.match(wscript, /js_entry_file='src\/pkjs\/index\.js'/u);
116112 });
117113
118-test("Alloy Message receives an explicit frozen-ID key Map", () => {
114+test("Alloy Message receives frozen keys and shared buffer bounds", () => {
119115 const source = moduleSource("./main");
120116 const expectedEntries = CANONICAL_MESSAGE_KEY_ORDER.map((alias) => [
121117 alias,
@@ -125,16 +121,23 @@ test("Alloy Message receives an explicit frozen-ID key Map", () => {
125121 assert.equal(WATCH_MESSAGE_KEY_MAP instanceof Map, true);
126122 assert.equal(Array.isArray(WATCH_MESSAGE_KEY_MAP), false);
127123 assert.deepEqual([...WATCH_MESSAGE_KEY_MAP], expectedEntries);
124+ assert.equal(WATCH_MESSAGE_INBOX_BYTES, 768);
125+ assert.equal(WATCH_MESSAGE_OUTBOX_BYTES, 192);
128126 assert.match(source, /keys:\s*APP_MESSAGE_KEY_MAP/u);
127+ assert.match(source, /input:\s*APP_MESSAGE_INBOX_BYTES/u);
128+ assert.match(source, /output:\s*APP_MESSAGE_OUTBOX_BYTES/u);
129129 assert.doesNotMatch(source, /keys:\s*APP_MESSAGE_KEY_ORDER/u);
130130 assert.doesNotMatch(source, /\bformat\s*:/u);
131131 });
132132
133133 test("package, canonical, companion, and embedded numeric maps are identical", () => {
134134 assert.deepEqual(packageMetadata.pebble.messageKeys, CANONICAL_MESSAGE_KEY);
135- assert.deepEqual(WATCH_MESSAGE_KEY, CANONICAL_MESSAGE_KEY);
135+ assert.deepEqual(
136+ Object.fromEntries(WATCH_MESSAGE_KEY_MAP),
137+ CANONICAL_MESSAGE_KEY
138+ );
136139 assert.deepEqual(companionContracts.APP_MESSAGE_KEY, CANONICAL_MESSAGE_KEY);
137- assert.deepEqual(WATCH_MESSAGE_KEY_ORDER, CANONICAL_MESSAGE_KEY_ORDER);
140+ assert.deepEqual([...WATCH_MESSAGE_KEY_MAP.keys()], CANONICAL_MESSAGE_KEY_ORDER);
138141 assert.deepEqual(companionContracts.APP_MESSAGE_KEY_ORDER, CANONICAL_MESSAGE_KEY_ORDER);
139142 assert.deepEqual(WATCH_LANGUAGE, CANONICAL_LANGUAGE);
140143 assert.deepEqual(companionContracts.WIRE_LANGUAGE, CANONICAL_LANGUAGE);
@@ -171,5 +174,8 @@ test("manifest declares every embedded module and no FFI or watch network module
171174 }
172175 }
173176 });
174- assert.deepEqual([...bareImports].sort(), ["pebble/message", "piu/MC"]);
177+ assert.deepEqual(
178+ [...bareImports].sort(),
179+ ["pebble/button", "pebble/message", "piu/MC", "timer"]
180+ );
175181 });
@@ -4,9 +4,9 @@ import { createRequire } from "node:module";
4 import { existsSync, readFileSync, readdirSync } from "node:fs";4 import { existsSync, readFileSync, readdirSync } from "node:fs";
5 import { fileURLToPath } from "node:url";5 import { fileURLToPath } from "node:url";
6 import {6 import {
7- APP_MESSAGE_KEY as WATCH_MESSAGE_KEY,7+ APP_MESSAGE_INBOX_BYTES as WATCH_MESSAGE_INBOX_BYTES,
8 APP_MESSAGE_KEY_MAP as WATCH_MESSAGE_KEY_MAP,8 APP_MESSAGE_KEY_MAP as WATCH_MESSAGE_KEY_MAP,
9- APP_MESSAGE_KEY_ORDER as WATCH_MESSAGE_KEY_ORDER,9+ APP_MESSAGE_OUTBOX_BYTES as WATCH_MESSAGE_OUTBOX_BYTES,
10 LANGUAGE as WATCH_LANGUAGE10 LANGUAGE as WATCH_LANGUAGE
11 } from "../src/embeddedjs/contracts.js";11 } from "../src/embeddedjs/contracts.js";
12 import {12 import {
@@ -33,6 +33,7 @@ const expectedModules = [
33 "./protocol",33 "./protocol",
34 "./message-queue",34 "./message-queue",
35 "./localization",35 "./localization",
36+ "./presentation",
36 "./storage",37 "./storage",
37 "./ui",38 "./ui",
38 "./contracts"39 "./contracts"
@@ -45,10 +46,10 @@ function moduleSource(name) {
45 function xsArenaBytes(field) {46 function xsArenaBytes(field) {
46 const constant = `XS_${field.toUpperCase()}_BYTES`;47 const constant = `XS_${field.toUpperCase()}_BYTES`;
47 const match = mdblSource.match(48 const match = mdblSource.match(
48- new RegExp(`^#define ${constant} \\((\\d+)U \\* 1024U\\)$`, "mu")49+ new RegExp(`^#define ${constant} \\((\\d+)U \\* (512|1024)U\\)$`, "mu")
49 );50 );
50- assert.notEqual(match, null, `${constant} must be an explicit KiB allocation`);51+ assert.notEqual(match, null, `${constant} must be an explicit allocation`);
51- return Number(match[1]) * 1024;52+ return Number(match[1]) * Number(match[2]);
52 }53 }
53 54
54 test("watch package is one multi-module Alloy app for emery and gabbro", () => {55 test("watch package is one multi-module Alloy app for emery and gabbro", () => {
@@ -70,11 +71,6 @@ test("Alloy bootstrap uses one bounded explicit XS creation record", () => {
70 const arenaValues = Object.values(arenas);71 const arenaValues = Object.values(arenas);
71 const arenaTotal = arenaValues.reduce((total, bytes) => total + bytes, 0);72 const arenaTotal = arenaValues.reduce((total, bytes) => total + bytes, 0);
72 73
73- assert.deepEqual(arenas, {
74- stack: 4 * 1024,
75- slot: 32 * 1024,
76- chunk: 20 * 1024
77- });
78 assert.equal(arenaValues.every((bytes) => bytes > 0), true);74 assert.equal(arenaValues.every((bytes) => bytes > 0), true);
79 assert.equal(arenaTotal, 56 * 1024);75 assert.equal(arenaTotal, 56 * 1024);
80 assert.equal(arenaTotal > 32 * 1024, true, "record must exceed the firmware static default");76 assert.equal(arenaTotal > 32 * 1024, true, "record must exceed the firmware static default");
@@ -115,7 +111,7 @@ test("Waf builds every target before bundling generated PKJS into one package",
115 assert.match(wscript, /js_entry_file='src\/pkjs\/index\.js'/u);111 assert.match(wscript, /js_entry_file='src\/pkjs\/index\.js'/u);
116 });112 });
117 113
118-test("Alloy Message receives an explicit frozen-ID key Map", () => {114+test("Alloy Message receives frozen keys and shared buffer bounds", () => {
119 const source = moduleSource("./main");115 const source = moduleSource("./main");
120 const expectedEntries = CANONICAL_MESSAGE_KEY_ORDER.map((alias) => [116 const expectedEntries = CANONICAL_MESSAGE_KEY_ORDER.map((alias) => [
121 alias,117 alias,
@@ -125,16 +121,23 @@ test("Alloy Message receives an explicit frozen-ID key Map", () => {
125 assert.equal(WATCH_MESSAGE_KEY_MAP instanceof Map, true);121 assert.equal(WATCH_MESSAGE_KEY_MAP instanceof Map, true);
126 assert.equal(Array.isArray(WATCH_MESSAGE_KEY_MAP), false);122 assert.equal(Array.isArray(WATCH_MESSAGE_KEY_MAP), false);
127 assert.deepEqual([...WATCH_MESSAGE_KEY_MAP], expectedEntries);123 assert.deepEqual([...WATCH_MESSAGE_KEY_MAP], expectedEntries);
124+ assert.equal(WATCH_MESSAGE_INBOX_BYTES, 768);
125+ assert.equal(WATCH_MESSAGE_OUTBOX_BYTES, 192);
128 assert.match(source, /keys:\s*APP_MESSAGE_KEY_MAP/u);126 assert.match(source, /keys:\s*APP_MESSAGE_KEY_MAP/u);
127+ assert.match(source, /input:\s*APP_MESSAGE_INBOX_BYTES/u);
128+ assert.match(source, /output:\s*APP_MESSAGE_OUTBOX_BYTES/u);
129 assert.doesNotMatch(source, /keys:\s*APP_MESSAGE_KEY_ORDER/u);129 assert.doesNotMatch(source, /keys:\s*APP_MESSAGE_KEY_ORDER/u);
130 assert.doesNotMatch(source, /\bformat\s*:/u);130 assert.doesNotMatch(source, /\bformat\s*:/u);
131 });131 });
132 132
133 test("package, canonical, companion, and embedded numeric maps are identical", () => {133 test("package, canonical, companion, and embedded numeric maps are identical", () => {
134 assert.deepEqual(packageMetadata.pebble.messageKeys, CANONICAL_MESSAGE_KEY);134 assert.deepEqual(packageMetadata.pebble.messageKeys, CANONICAL_MESSAGE_KEY);
135- assert.deepEqual(WATCH_MESSAGE_KEY, CANONICAL_MESSAGE_KEY);135+ assert.deepEqual(
136+ Object.fromEntries(WATCH_MESSAGE_KEY_MAP),
137+ CANONICAL_MESSAGE_KEY
138+ );
136 assert.deepEqual(companionContracts.APP_MESSAGE_KEY, CANONICAL_MESSAGE_KEY);139 assert.deepEqual(companionContracts.APP_MESSAGE_KEY, CANONICAL_MESSAGE_KEY);
137- assert.deepEqual(WATCH_MESSAGE_KEY_ORDER, CANONICAL_MESSAGE_KEY_ORDER);140+ assert.deepEqual([...WATCH_MESSAGE_KEY_MAP.keys()], CANONICAL_MESSAGE_KEY_ORDER);
138 assert.deepEqual(companionContracts.APP_MESSAGE_KEY_ORDER, CANONICAL_MESSAGE_KEY_ORDER);141 assert.deepEqual(companionContracts.APP_MESSAGE_KEY_ORDER, CANONICAL_MESSAGE_KEY_ORDER);
139 assert.deepEqual(WATCH_LANGUAGE, CANONICAL_LANGUAGE);142 assert.deepEqual(WATCH_LANGUAGE, CANONICAL_LANGUAGE);
140 assert.deepEqual(companionContracts.WIRE_LANGUAGE, CANONICAL_LANGUAGE);143 assert.deepEqual(companionContracts.WIRE_LANGUAGE, CANONICAL_LANGUAGE);
@@ -171,5 +174,8 @@ test("manifest declares every embedded module and no FFI or watch network module
171 }174 }
172 }175 }
173 });176 });
174- assert.deepEqual([...bareImports].sort(), ["pebble/message", "piu/MC"]);177+ assert.deepEqual(
178+ [...bareImports].sort(),
179+ ["pebble/button", "pebble/message", "piu/MC", "timer"]
180+ );
175 });181 });
modified packages/watch/test/model.test.js +373 -153
@@ -1,177 +1,397 @@
11 import test from "node:test";
22 import assert from "node:assert/strict";
33 import {
4+ DEPARTURE_STATUS,
5+ ERROR_CODE,
6+ FRESHNESS,
47 KEY_STATUS,
5- MESSAGE_TYPE,
6- REQUEST_TRIGGER,
7- SCHEMA_VERSION
8+ REQUEST_TRIGGER
89 } from "../src/embeddedjs/contracts.js";
9-import { createController } from "../src/embeddedjs/controller.js";
10-import { RECEIVE_RESULT } from "../src/embeddedjs/protocol.js";
11-import WatchModel from "../src/embeddedjs/model.js";
12-
13-const FAVORITE = {
14- id: "foundation-home",
15- serviceId: "opaque:foundation:service:1",
16- displayName: "Commute home",
17- stopLabel: "Châtelet",
18- lineLabel: "Métro 1",
19- destinationLabel: "La Défense",
20- sortOrder: 0
21-};
22-
23-function wire(type, entries = []) {
24- return new Map([
25- ["SCHEMA_VERSION", SCHEMA_VERSION],
26- ["MESSAGE_TYPE", type],
27- ...entries
28- ]);
10+import WatchModel, { WATCH_STATE } from "../src/embeddedjs/model.js";
11+
12+const NOW_SECONDS = 1_788_000_000;
13+const NOW_MS = NOW_SECONDS * 1000;
14+
15+function favorite(id, sortOrder, displayName) {
16+ const value = {
17+ id,
18+ serviceId: "opaque:service:" + id,
19+ stopLabel: "Stop " + id,
20+ lineLabel: "Line " + id,
21+ destinationLabel: "Destination " + id,
22+ sortOrder
23+ };
24+ if (displayName !== undefined) value.displayName = displayName;
25+ return value;
2926 }
3027
31-function configurationMessages(id) {
32- return [
33- wire(MESSAGE_TYPE.CONFIG_BEGIN, [
34- ["REQUEST_ID", id],
35- ["ITEM_COUNT", 1],
36- ["KEY_STATUS", KEY_STATUS.CONFIGURED],
37- ["DISPLAY_NAME", "fr"]
38- ]),
39- wire(MESSAGE_TYPE.FAVORITE, [
40- ["REQUEST_ID", id],
41- ["ITEM_INDEX", 0],
42- ["FAVORITE_ID", FAVORITE.id],
43- ["SERVICE_ID", FAVORITE.serviceId],
44- ["DISPLAY_NAME", FAVORITE.displayName],
45- ["STOP_LABEL", FAVORITE.stopLabel],
46- ["LINE_LABEL", FAVORITE.lineLabel],
47- ["DESTINATION_LABEL", FAVORITE.destinationLabel],
48- ["SORT_ORDER", FAVORITE.sortOrder]
49- ]),
50- wire(MESSAGE_TYPE.CONFIG_COMMIT, [["REQUEST_ID", id]])
51- ];
28+const FIRST = favorite("z-last-by-name", 80, "Trajet préféré très long");
29+const SECOND = favorite("a-first-by-name", 5);
30+const THIRD = favorite("middle", 20, "Middle");
31+
32+function configuration(overrides = {}) {
33+ return {
34+ keyStatus: KEY_STATUS.CONFIGURED,
35+ language: "fr",
36+ favorites: [FIRST],
37+ ...overrides
38+ };
5239 }
5340
54-function resultMessages(requestId) {
55- return [
56- wire(MESSAGE_TYPE.RESULT_BEGIN, [
57- ["REQUEST_ID", requestId],
58- ["FAVORITE_ID", FAVORITE.id],
59- ["ITEM_COUNT", 1],
60- ["FETCHED_AT", 1_788_000_000],
61- ["FRESHNESS", 0]
62- ]),
63- wire(MESSAGE_TYPE.DEPARTURE, [
64- ["REQUEST_ID", requestId],
65- ["FAVORITE_ID", FAVORITE.id],
66- ["ITEM_INDEX", 0],
67- ["EXPECTED_AT", 1_788_000_120],
68- ["MINUTES", 2],
69- ["DEPARTURE_STATUS", 0],
70- ["NEXT_INTERVAL_MINUTES", 4]
71- ]),
72- wire(MESSAGE_TYPE.RESULT_COMMIT, [
73- ["REQUEST_ID", requestId],
74- ["FAVORITE_ID", FAVORITE.id]
75- ])
76- ];
41+function departure(overrides = {}) {
42+ return {
43+ expectedAt: NOW_SECONDS + 120,
44+ minutes: 99,
45+ status: DEPARTURE_STATUS[0],
46+ ...overrides
47+ };
7748 }
7849
79-class FakeQueue {
80- constructor() {
81- this.messages = [];
82- this.closed = 0;
83- }
50+function result(overrides = {}) {
51+ return {
52+ requestId: "result-1",
53+ favoriteId: FIRST.id,
54+ fetchedAt: NOW_SECONDS,
55+ freshness: FRESHNESS[0],
56+ departures: [departure()],
57+ ...overrides
58+ };
59+}
8460
85- enqueue(message) {
86- this.messages.push(message);
87- return true;
88- }
61+function protocolError(code = ERROR_CODE[3], favoriteId = FIRST.id) {
62+ const value = {
63+ requestId: "error-1",
64+ code,
65+ occurredAt: NOW_SECONDS
66+ };
67+ if (favoriteId !== undefined) value.favoriteId = favoriteId;
68+ return value;
69+}
8970
90- close() {
91- this.closed += 1;
92- }
71+function commit(model, options = {}) {
72+ model.commitProtocol({
73+ configuration: options.configuration === undefined
74+ ? configuration()
75+ : options.configuration,
76+ result: options.result === undefined ? null : options.result,
77+ error: options.error === undefined ? null : options.error
78+ }, options.event || "CONFIG_COMMITTED");
79+ return model;
9380 }
9481
95-class FakeView {
96- constructor() {
97- this.renders = [];
82+test("WATCH_STATE and the initial snapshot expose only the consultation model", () => {
83+ assert.deepEqual(WATCH_STATE, {
84+ UNCONFIGURED: "UNCONFIGURED",
85+ LOADING: "LOADING",
86+ READY: "READY",
87+ STALE: "STALE",
88+ UNAVAILABLE: "UNAVAILABLE"
89+ });
90+ assert.equal(Object.isFrozen(WATCH_STATE), true);
91+
92+ const snapshot = new WatchModel().snapshot(NOW_MS);
93+ assert.deepEqual(snapshot, {
94+ state: WATCH_STATE.UNCONFIGURED,
95+ language: "en",
96+ nowMs: NOW_MS,
97+ activeFavorite: null,
98+ result: null,
99+ error: null,
100+ sendFailed: false,
101+ expectedRequest: null
102+ });
103+ assert.equal("favorite" in snapshot, false);
104+});
105+
106+test("configuration and API key failures follow the fixed state precedence", () => {
107+ const complete = result();
108+ const scenarios = [
109+ {
110+ name: "missing configuration",
111+ configuration: null,
112+ error: protocolError(ERROR_CODE[1]),
113+ expected: WATCH_STATE.UNCONFIGURED
114+ },
115+ {
116+ name: "missing key before an invalid-key error",
117+ configuration: configuration({ keyStatus: KEY_STATUS.MISSING }),
118+ error: protocolError(ERROR_CODE[1]),
119+ expected: WATCH_STATE.UNCONFIGURED
120+ },
121+ {
122+ name: "empty favorites before an invalid-key error",
123+ configuration: configuration({ favorites: [] }),
124+ error: protocolError(ERROR_CODE[1]),
125+ expected: WATCH_STATE.UNCONFIGURED
126+ },
127+ {
128+ name: "required-key error before an invalid key status",
129+ configuration: configuration({ keyStatus: KEY_STATUS.INVALID }),
130+ error: protocolError(ERROR_CODE[0]),
131+ expected: WATCH_STATE.UNCONFIGURED
132+ },
133+ {
134+ name: "invalid key status before a complete result",
135+ configuration: configuration({ keyStatus: KEY_STATUS.INVALID }),
136+ error: null,
137+ expected: WATCH_STATE.UNAVAILABLE
138+ },
139+ {
140+ name: "invalid-key error before a complete result",
141+ configuration: configuration(),
142+ error: protocolError(ERROR_CODE[1]),
143+ expected: WATCH_STATE.UNAVAILABLE
144+ }
145+ ];
146+
147+ for (const scenario of scenarios) {
148+ const model = commit(new WatchModel(), {
149+ configuration: scenario.configuration,
150+ result: complete,
151+ error: scenario.error
152+ });
153+ assert.equal(model.snapshot(NOW_MS).state, scenario.expected, scenario.name);
154+ }
155+});
156+
157+test("configured states distinguish loading, unavailable, ready, and stale", () => {
158+ const loading = commit(new WatchModel());
159+ assert.equal(loading.snapshot(NOW_MS).state, WATCH_STATE.LOADING);
160+ assert.deepEqual(
161+ loading.beginRequest("app-open", REQUEST_TRIGGER.APP_OPEN),
162+ {
163+ requestId: "app-open",
164+ favoriteId: FIRST.id,
165+ trigger: REQUEST_TRIGGER.APP_OPEN
166+ }
167+ );
168+ assert.equal(loading.snapshot(NOW_MS).state, WATCH_STATE.LOADING);
169+ loading.markSendFailure();
170+ assert.equal(loading.snapshot(NOW_MS).state, WATCH_STATE.UNAVAILABLE);
171+
172+ const ready = commit(new WatchModel(), {
173+ result: result(),
174+ event: "RESULT_COMMITTED"
175+ });
176+ assert.equal(ready.snapshot(NOW_MS).state, WATCH_STATE.READY);
177+ ready.beginRequest("refresh", REQUEST_TRIGGER.MANUAL_SELECT);
178+ assert.equal(ready.snapshot(NOW_MS).state, WATCH_STATE.READY);
179+ ready.markSendFailure();
180+ assert.equal(ready.snapshot(NOW_MS).state, WATCH_STATE.STALE);
181+
182+ const failedRefresh = commit(new WatchModel(), {
183+ result: result(),
184+ error: protocolError(),
185+ event: "ERROR_COMMITTED"
186+ });
187+ assert.equal(failedRefresh.snapshot(NOW_MS).state, WATCH_STATE.STALE);
188+
189+ const unavailable = commit(new WatchModel(), {
190+ error: protocolError(),
191+ event: "ERROR_COMMITTED"
192+ });
193+ assert.equal(unavailable.snapshot(NOW_MS).state, WATCH_STATE.UNAVAILABLE);
194+
195+ const mismatchedResult = commit(new WatchModel(), {
196+ result: result({ favoriteId: SECOND.id }),
197+ event: "RESULT_COMMITTED"
198+ });
199+ assert.equal(mismatchedResult.snapshot(NOW_MS).state, WATCH_STATE.LOADING);
200+ assert.equal(mismatchedResult.snapshot(NOW_MS).result, null);
201+
202+ const unrelatedFailure = commit(new WatchModel(), {
203+ result: result(),
204+ error: protocolError(ERROR_CODE[3], SECOND.id),
205+ event: "ERROR_COMMITTED"
206+ });
207+ assert.equal(unrelatedFailure.snapshot(NOW_MS).state, WATCH_STATE.READY);
208+ assert.equal(unrelatedFailure.snapshot(NOW_MS).error, null);
209+});
210+
211+test("source age and frozen freshness use the exact stale boundary", () => {
212+ const model = commit(new WatchModel(), {
213+ result: result(),
214+ event: "RESULT_COMMITTED"
215+ });
216+
217+ assert.equal(model.snapshot(NOW_MS - 1).state, WATCH_STATE.STALE);
218+ assert.equal(model.snapshot(NOW_MS).state, WATCH_STATE.READY);
219+ assert.equal(model.snapshot(NOW_MS + 59_999).state, WATCH_STATE.READY);
220+ assert.equal(model.snapshot(NOW_MS + 60_000).state, WATCH_STATE.STALE);
221+
222+ commit(model, {
223+ result: result({ freshness: FRESHNESS[3] }),
224+ event: "RESULT_COMMITTED"
225+ });
226+ assert.equal(model.snapshot(NOW_MS).state, WATCH_STATE.STALE);
227+});
228+
229+test("selection follows committed order and clamps without rewriting favorites", () => {
230+ const favorites = [FIRST, SECOND, THIRD];
231+ const before = structuredClone(favorites);
232+ const model = commit(new WatchModel(), {
233+ configuration: configuration({ favorites })
234+ });
235+
236+ assert.deepEqual(model.snapshot(NOW_MS).activeFavorite, FIRST);
237+ assert.equal(model.moveSelection(-1), false);
238+ assert.equal(model.moveSelection(1), true);
239+ assert.deepEqual(model.snapshot(NOW_MS).activeFavorite, SECOND);
240+ assert.equal(model.moveSelection(20), true);
241+ assert.deepEqual(model.snapshot(NOW_MS).activeFavorite, THIRD);
242+ assert.equal(model.moveSelection(1), false);
243+ assert.equal(model.moveSelection(-20), true);
244+ assert.deepEqual(model.snapshot(NOW_MS).activeFavorite, FIRST);
245+ assert.equal(model.moveSelection(-1), false);
246+ assert.deepEqual(favorites, before);
247+});
248+
249+test("configuration replacement preserves the active ID or falls back to index zero", () => {
250+ const model = commit(new WatchModel(), {
251+ configuration: configuration({ favorites: [FIRST, SECOND, THIRD] })
252+ });
253+ assert.equal(model.moveSelection(1), true);
254+
255+ const updatedSecond = {
256+ ...SECOND,
257+ stopLabel: "Nouvel arrêt",
258+ sortOrder: 700
259+ };
260+ commit(model, {
261+ configuration: configuration({
262+ favorites: [THIRD, updatedSecond, FIRST]
263+ })
264+ });
265+ assert.deepEqual(model.snapshot(NOW_MS).activeFavorite, updatedSecond);
266+
267+ commit(model, {
268+ configuration: configuration({ favorites: [THIRD, FIRST] })
269+ });
270+ assert.deepEqual(model.snapshot(NOW_MS).activeFavorite, THIRD);
271+
272+ commit(model, {
273+ configuration: configuration({ favorites: [] })
274+ });
275+ assert.equal(model.snapshot(NOW_MS).activeFavorite, null);
276+});
277+
278+test("beginRequest accepts only configured active-favorite consultation triggers", () => {
279+ const unavailableModels = [
280+ new WatchModel(),
281+ commit(new WatchModel(), {
282+ configuration: configuration({ keyStatus: KEY_STATUS.MISSING })
283+ }),
284+ commit(new WatchModel(), {
285+ configuration: configuration({ keyStatus: KEY_STATUS.INVALID })
286+ }),
287+ commit(new WatchModel(), {
288+ configuration: configuration({ favorites: [] })
289+ })
290+ ];
291+ for (const model of unavailableModels) {
292+ assert.equal(model.beginRequest("rejected", REQUEST_TRIGGER.APP_OPEN), null);
98293 }
99294
100- render(snapshot) {
101- this.renders.push(snapshot);
295+ const model = commit(new WatchModel());
296+ const triggers = [
297+ REQUEST_TRIGGER.APP_OPEN,
298+ REQUEST_TRIGGER.FAVORITE_SELECTION,
299+ REQUEST_TRIGGER.MANUAL_SELECT
300+ ];
301+ for (let index = 0; index < triggers.length; index += 1) {
302+ const requestId = "accepted-" + index;
303+ assert.deepEqual(model.beginRequest(requestId, triggers[index]), {
304+ requestId,
305+ favoriteId: FIRST.id,
306+ trigger: triggers[index]
307+ });
308+ model.cancelRequest();
102309 }
103310
104- close() {}
105-}
311+ for (const trigger of [-1, 3, "APP_OPEN", null, undefined]) {
312+ assert.equal(model.beginRequest("invalid-trigger", trigger), null);
313+ }
314+});
106315
107-function harness() {
108- const queue = new FakeQueue();
109- const view = new FakeView();
110- const controller = createController({
111- clock: { now: () => 1_788_000_000_000 },
112- queue,
113- model: new WatchModel(),
114- view
115- }).start();
116- return { queue, view, controller };
117-}
316+test("pending requests suppress duplicates until a complete active result exists", () => {
317+ const loading = commit(new WatchModel());
318+ const first = loading.beginRequest("pending-1", REQUEST_TRIGGER.APP_OPEN);
319+ assert.equal(
320+ loading.beginRequest("pending-2", REQUEST_TRIGGER.FAVORITE_SELECTION),
321+ null
322+ );
323+ assert.deepEqual(loading.snapshot(NOW_MS).expectedRequest, first);
118324
119-test("foundation model exposes the first committed favorite and one fixture request", () => {
120- const model = new WatchModel();
121- assert.deepEqual(model.commitProtocol({
122- configuration: {
123- keyStatus: KEY_STATUS.MISSING,
124- language: "en",
125- favorites: [FAVORITE]
126- },
127- result: null,
128- error: null
129- }, "CONFIG_COMMITTED"), { requestFixture: false });
130- assert.equal(model.beginFixtureRequest("missing-key"), null);
131-
132- const effect = model.commitProtocol({
133- configuration: {
134- keyStatus: KEY_STATUS.CONFIGURED,
135- language: "fr",
136- favorites: [FAVORITE]
137- },
138- result: null,
139- error: null
140- }, "CONFIG_COMMITTED");
141-
142- assert.deepEqual(effect, { requestFixture: true });
143- const request = model.beginFixtureRequest("foundation-1");
144- assert.deepEqual(request, {
145- requestId: "foundation-1",
146- favoriteId: FAVORITE.id,
147- trigger: REQUEST_TRIGGER.APP_OPEN
148- });
149- assert.equal(model.beginFixtureRequest("foundation-duplicate"), null);
150- assert.equal(model.snapshot().favorite.id, FAVORITE.id);
151- assert.equal(model.snapshot().language, "fr");
325+ const ready = commit(new WatchModel(), {
326+ result: result(),
327+ event: "RESULT_COMMITTED"
328+ });
329+ ready.beginRequest("refresh-1", REQUEST_TRIGGER.FAVORITE_SELECTION);
330+ assert.deepEqual(
331+ ready.beginRequest("refresh-2", REQUEST_TRIGGER.MANUAL_SELECT),
332+ {
333+ requestId: "refresh-2",
334+ favoriteId: FIRST.id,
335+ trigger: REQUEST_TRIGGER.MANUAL_SELECT
336+ }
337+ );
338+ const snapshot = ready.snapshot(NOW_MS);
339+ assert.equal(snapshot.state, WATCH_STATE.READY);
340+ assert.equal(snapshot.result.requestId, "result-1");
341+ assert.equal(snapshot.expectedRequest.requestId, "refresh-2");
152342 });
153343
154-test("controller performs one APP_OPEN fixture round trip and preserves the committed result", () => {
155- const target = harness();
156- configurationMessages("config-1").forEach(target.controller.onReadable);
157-
158- assert.equal(target.queue.messages.length, 1);
159- assert.equal(target.queue.messages[0].get("REQUEST_TRIGGER"), REQUEST_TRIGGER.APP_OPEN);
160- const requestId = target.queue.messages[0].get("REQUEST_ID");
161- resultMessages(requestId).forEach(target.controller.onReadable);
162-
163- const committed = target.view.renders.at(-1);
164- assert.equal(committed.favorite.id, FAVORITE.id);
165- assert.equal(committed.result.favoriteId, FAVORITE.id);
166- assert.equal(committed.result.departures[0].minutes, 2);
167-
168- const rejected = target.controller.onReadable(new Map([
169- ["SCHEMA_VERSION", SCHEMA_VERSION + 1],
170- ["MESSAGE_TYPE", MESSAGE_TYPE.CONFIG_BEGIN]
171- ]));
172- assert.equal(rejected, RECEIVE_RESULT.REJECTED);
173- assert.equal(target.view.renders.at(-1), committed);
174-
175- configurationMessages("config-2").forEach(target.controller.onReadable);
176- assert.equal(target.queue.messages.length, 1);
344+test("snapshot derives countdowns from expectedAt without changing wire data", () => {
345+ const received = result({
346+ sourceUpdatedAt: NOW_SECONDS - 10,
347+ departures: [
348+ departure({
349+ expectedAt: NOW_SECONDS + 61,
350+ minutes: 44,
351+ aimedAt: NOW_SECONDS + 40
352+ }),
353+ departure({
354+ expectedAt: NOW_SECONDS,
355+ minutes: 27,
356+ status: DEPARTURE_STATUS[1],
357+ nextIntervalMinutes: 4
358+ }),
359+ departure({
360+ expectedAt: NOW_SECONDS - 120,
361+ minutes: -2,
362+ status: DEPARTURE_STATUS[2]
363+ }),
364+ departure({
365+ expectedAt: NOW_SECONDS - 61,
366+ minutes: 8,
367+ status: DEPARTURE_STATUS[3]
368+ })
369+ ]
370+ });
371+ const original = structuredClone(received);
372+ const model = commit(new WatchModel(), {
373+ result: received,
374+ event: "RESULT_COMMITTED"
375+ });
376+
377+ const snapshot = model.snapshot(NOW_MS);
378+ assert.equal(snapshot.nowMs, NOW_MS);
379+ assert.deepEqual(
380+ snapshot.result.departures.map((item) => item.minutes),
381+ [44, 27, -2, 8]
382+ );
383+ assert.deepEqual(
384+ snapshot.result.departures.map((item) => item.countdownMinutes),
385+ [2, 0, null, -1]
386+ );
387+ assert.equal(snapshot.result.departures[2].status, DEPARTURE_STATUS[2]);
388+ assert.deepEqual(received, original);
389+
390+ snapshot.result.departures[0].minutes = -100;
391+ const oneMinuteLater = model.snapshot(NOW_MS + 60_000);
392+ assert.equal(oneMinuteLater.result.departures[0].minutes, 44);
393+ assert.equal(oneMinuteLater.result.departures[0].countdownMinutes, 1);
394+ assert.equal(oneMinuteLater.result.departures[1].countdownMinutes, -1);
395+ assert.equal(oneMinuteLater.result.departures[2].countdownMinutes, null);
396+ assert.deepEqual(received, original);
177397 });
@@ -1,177 +1,397 @@
1 import test from "node:test";1 import test from "node:test";
2 import assert from "node:assert/strict";2 import assert from "node:assert/strict";
3 import {3 import {
4+ DEPARTURE_STATUS,
5+ ERROR_CODE,
6+ FRESHNESS,
4 KEY_STATUS,7 KEY_STATUS,
5- MESSAGE_TYPE,8+ REQUEST_TRIGGER
6- REQUEST_TRIGGER,
7- SCHEMA_VERSION
8 } from "../src/embeddedjs/contracts.js";9 } from "../src/embeddedjs/contracts.js";
9-import { createController } from "../src/embeddedjs/controller.js";10+import WatchModel, { WATCH_STATE } from "../src/embeddedjs/model.js";
10-import { RECEIVE_RESULT } from "../src/embeddedjs/protocol.js";11+
11-import WatchModel from "../src/embeddedjs/model.js";12+const NOW_SECONDS = 1_788_000_000;
12-13+const NOW_MS = NOW_SECONDS * 1000;
13-const FAVORITE = {14+
14- id: "foundation-home",15+function favorite(id, sortOrder, displayName) {
15- serviceId: "opaque:foundation:service:1",16+ const value = {
16- displayName: "Commute home",17+ id,
17- stopLabel: "Châtelet",18+ serviceId: "opaque:service:" + id,
18- lineLabel: "Métro 1",19+ stopLabel: "Stop " + id,
19- destinationLabel: "La Défense",20+ lineLabel: "Line " + id,
20- sortOrder: 021+ destinationLabel: "Destination " + id,
21-};22+ sortOrder
22-23+ };
23-function wire(type, entries = []) {24+ if (displayName !== undefined) value.displayName = displayName;
24- return new Map([25+ return value;
25- ["SCHEMA_VERSION", SCHEMA_VERSION],
26- ["MESSAGE_TYPE", type],
27- ...entries
28- ]);
29 }26 }
30 27
31-function configurationMessages(id) {28+const FIRST = favorite("z-last-by-name", 80, "Trajet préféré très long");
32- return [29+const SECOND = favorite("a-first-by-name", 5);
33- wire(MESSAGE_TYPE.CONFIG_BEGIN, [30+const THIRD = favorite("middle", 20, "Middle");
34- ["REQUEST_ID", id],31+
35- ["ITEM_COUNT", 1],32+function configuration(overrides = {}) {
36- ["KEY_STATUS", KEY_STATUS.CONFIGURED],33+ return {
37- ["DISPLAY_NAME", "fr"]34+ keyStatus: KEY_STATUS.CONFIGURED,
38- ]),35+ language: "fr",
39- wire(MESSAGE_TYPE.FAVORITE, [36+ favorites: [FIRST],
40- ["REQUEST_ID", id],37+ ...overrides
41- ["ITEM_INDEX", 0],38+ };
42- ["FAVORITE_ID", FAVORITE.id],
43- ["SERVICE_ID", FAVORITE.serviceId],
44- ["DISPLAY_NAME", FAVORITE.displayName],
45- ["STOP_LABEL", FAVORITE.stopLabel],
46- ["LINE_LABEL", FAVORITE.lineLabel],
47- ["DESTINATION_LABEL", FAVORITE.destinationLabel],
48- ["SORT_ORDER", FAVORITE.sortOrder]
49- ]),
50- wire(MESSAGE_TYPE.CONFIG_COMMIT, [["REQUEST_ID", id]])
51- ];
52 }39 }
53 40
54-function resultMessages(requestId) {41+function departure(overrides = {}) {
55- return [42+ return {
56- wire(MESSAGE_TYPE.RESULT_BEGIN, [43+ expectedAt: NOW_SECONDS + 120,
57- ["REQUEST_ID", requestId],44+ minutes: 99,
58- ["FAVORITE_ID", FAVORITE.id],45+ status: DEPARTURE_STATUS[0],
59- ["ITEM_COUNT", 1],46+ ...overrides
60- ["FETCHED_AT", 1_788_000_000],47+ };
61- ["FRESHNESS", 0]
62- ]),
63- wire(MESSAGE_TYPE.DEPARTURE, [
64- ["REQUEST_ID", requestId],
65- ["FAVORITE_ID", FAVORITE.id],
66- ["ITEM_INDEX", 0],
67- ["EXPECTED_AT", 1_788_000_120],
68- ["MINUTES", 2],
69- ["DEPARTURE_STATUS", 0],
70- ["NEXT_INTERVAL_MINUTES", 4]
71- ]),
72- wire(MESSAGE_TYPE.RESULT_COMMIT, [
73- ["REQUEST_ID", requestId],
74- ["FAVORITE_ID", FAVORITE.id]
75- ])
76- ];
77 }48 }
78 49
79-class FakeQueue {50+function result(overrides = {}) {
80- constructor() {51+ return {
81- this.messages = [];52+ requestId: "result-1",
82- this.closed = 0;53+ favoriteId: FIRST.id,
83- }54+ fetchedAt: NOW_SECONDS,
55+ freshness: FRESHNESS[0],
56+ departures: [departure()],
57+ ...overrides
58+ };
59+}
84 60
85- enqueue(message) {61+function protocolError(code = ERROR_CODE[3], favoriteId = FIRST.id) {
86- this.messages.push(message);62+ const value = {
87- return true;63+ requestId: "error-1",
88- }64+ code,
65+ occurredAt: NOW_SECONDS
66+ };
67+ if (favoriteId !== undefined) value.favoriteId = favoriteId;
68+ return value;
69+}
89 70
90- close() {71+function commit(model, options = {}) {
91- this.closed += 1;72+ model.commitProtocol({
92- }73+ configuration: options.configuration === undefined
74+ ? configuration()
75+ : options.configuration,
76+ result: options.result === undefined ? null : options.result,
77+ error: options.error === undefined ? null : options.error
78+ }, options.event || "CONFIG_COMMITTED");
79+ return model;
93 }80 }
94 81
95-class FakeView {82+test("WATCH_STATE and the initial snapshot expose only the consultation model", () => {
96- constructor() {83+ assert.deepEqual(WATCH_STATE, {
97- this.renders = [];84+ UNCONFIGURED: "UNCONFIGURED",
85+ LOADING: "LOADING",
86+ READY: "READY",
87+ STALE: "STALE",
88+ UNAVAILABLE: "UNAVAILABLE"
89+ });
90+ assert.equal(Object.isFrozen(WATCH_STATE), true);
91+
92+ const snapshot = new WatchModel().snapshot(NOW_MS);
93+ assert.deepEqual(snapshot, {
94+ state: WATCH_STATE.UNCONFIGURED,
95+ language: "en",
96+ nowMs: NOW_MS,
97+ activeFavorite: null,
98+ result: null,
99+ error: null,
100+ sendFailed: false,
101+ expectedRequest: null
102+ });
103+ assert.equal("favorite" in snapshot, false);
104+});
105+
106+test("configuration and API key failures follow the fixed state precedence", () => {
107+ const complete = result();
108+ const scenarios = [
109+ {
110+ name: "missing configuration",
111+ configuration: null,
112+ error: protocolError(ERROR_CODE[1]),
113+ expected: WATCH_STATE.UNCONFIGURED
114+ },
115+ {
116+ name: "missing key before an invalid-key error",
117+ configuration: configuration({ keyStatus: KEY_STATUS.MISSING }),
118+ error: protocolError(ERROR_CODE[1]),
119+ expected: WATCH_STATE.UNCONFIGURED
120+ },
121+ {
122+ name: "empty favorites before an invalid-key error",
123+ configuration: configuration({ favorites: [] }),
124+ error: protocolError(ERROR_CODE[1]),
125+ expected: WATCH_STATE.UNCONFIGURED
126+ },
127+ {
128+ name: "required-key error before an invalid key status",
129+ configuration: configuration({ keyStatus: KEY_STATUS.INVALID }),
130+ error: protocolError(ERROR_CODE[0]),
131+ expected: WATCH_STATE.UNCONFIGURED
132+ },
133+ {
134+ name: "invalid key status before a complete result",
135+ configuration: configuration({ keyStatus: KEY_STATUS.INVALID }),
136+ error: null,
137+ expected: WATCH_STATE.UNAVAILABLE
138+ },
139+ {
140+ name: "invalid-key error before a complete result",
141+ configuration: configuration(),
142+ error: protocolError(ERROR_CODE[1]),
143+ expected: WATCH_STATE.UNAVAILABLE
144+ }
145+ ];
146+
147+ for (const scenario of scenarios) {
148+ const model = commit(new WatchModel(), {
149+ configuration: scenario.configuration,
150+ result: complete,
151+ error: scenario.error
152+ });
153+ assert.equal(model.snapshot(NOW_MS).state, scenario.expected, scenario.name);
154+ }
155+});
156+
157+test("configured states distinguish loading, unavailable, ready, and stale", () => {
158+ const loading = commit(new WatchModel());
159+ assert.equal(loading.snapshot(NOW_MS).state, WATCH_STATE.LOADING);
160+ assert.deepEqual(
161+ loading.beginRequest("app-open", REQUEST_TRIGGER.APP_OPEN),
162+ {
163+ requestId: "app-open",
164+ favoriteId: FIRST.id,
165+ trigger: REQUEST_TRIGGER.APP_OPEN
166+ }
167+ );
168+ assert.equal(loading.snapshot(NOW_MS).state, WATCH_STATE.LOADING);
169+ loading.markSendFailure();
170+ assert.equal(loading.snapshot(NOW_MS).state, WATCH_STATE.UNAVAILABLE);
171+
172+ const ready = commit(new WatchModel(), {
173+ result: result(),
174+ event: "RESULT_COMMITTED"
175+ });
176+ assert.equal(ready.snapshot(NOW_MS).state, WATCH_STATE.READY);
177+ ready.beginRequest("refresh", REQUEST_TRIGGER.MANUAL_SELECT);
178+ assert.equal(ready.snapshot(NOW_MS).state, WATCH_STATE.READY);
179+ ready.markSendFailure();
180+ assert.equal(ready.snapshot(NOW_MS).state, WATCH_STATE.STALE);
181+
182+ const failedRefresh = commit(new WatchModel(), {
183+ result: result(),
184+ error: protocolError(),
185+ event: "ERROR_COMMITTED"
186+ });
187+ assert.equal(failedRefresh.snapshot(NOW_MS).state, WATCH_STATE.STALE);
188+
189+ const unavailable = commit(new WatchModel(), {
190+ error: protocolError(),
191+ event: "ERROR_COMMITTED"
192+ });
193+ assert.equal(unavailable.snapshot(NOW_MS).state, WATCH_STATE.UNAVAILABLE);
194+
195+ const mismatchedResult = commit(new WatchModel(), {
196+ result: result({ favoriteId: SECOND.id }),
197+ event: "RESULT_COMMITTED"
198+ });
199+ assert.equal(mismatchedResult.snapshot(NOW_MS).state, WATCH_STATE.LOADING);
200+ assert.equal(mismatchedResult.snapshot(NOW_MS).result, null);
201+
202+ const unrelatedFailure = commit(new WatchModel(), {
203+ result: result(),
204+ error: protocolError(ERROR_CODE[3], SECOND.id),
205+ event: "ERROR_COMMITTED"
206+ });
207+ assert.equal(unrelatedFailure.snapshot(NOW_MS).state, WATCH_STATE.READY);
208+ assert.equal(unrelatedFailure.snapshot(NOW_MS).error, null);
209+});
210+
211+test("source age and frozen freshness use the exact stale boundary", () => {
212+ const model = commit(new WatchModel(), {
213+ result: result(),
214+ event: "RESULT_COMMITTED"
215+ });
216+
217+ assert.equal(model.snapshot(NOW_MS - 1).state, WATCH_STATE.STALE);
218+ assert.equal(model.snapshot(NOW_MS).state, WATCH_STATE.READY);
219+ assert.equal(model.snapshot(NOW_MS + 59_999).state, WATCH_STATE.READY);
220+ assert.equal(model.snapshot(NOW_MS + 60_000).state, WATCH_STATE.STALE);
221+
222+ commit(model, {
223+ result: result({ freshness: FRESHNESS[3] }),
224+ event: "RESULT_COMMITTED"
225+ });
226+ assert.equal(model.snapshot(NOW_MS).state, WATCH_STATE.STALE);
227+});
228+
229+test("selection follows committed order and clamps without rewriting favorites", () => {
230+ const favorites = [FIRST, SECOND, THIRD];
231+ const before = structuredClone(favorites);
232+ const model = commit(new WatchModel(), {
233+ configuration: configuration({ favorites })
234+ });
235+
236+ assert.deepEqual(model.snapshot(NOW_MS).activeFavorite, FIRST);
237+ assert.equal(model.moveSelection(-1), false);
238+ assert.equal(model.moveSelection(1), true);
239+ assert.deepEqual(model.snapshot(NOW_MS).activeFavorite, SECOND);
240+ assert.equal(model.moveSelection(20), true);
241+ assert.deepEqual(model.snapshot(NOW_MS).activeFavorite, THIRD);
242+ assert.equal(model.moveSelection(1), false);
243+ assert.equal(model.moveSelection(-20), true);
244+ assert.deepEqual(model.snapshot(NOW_MS).activeFavorite, FIRST);
245+ assert.equal(model.moveSelection(-1), false);
246+ assert.deepEqual(favorites, before);
247+});
248+
249+test("configuration replacement preserves the active ID or falls back to index zero", () => {
250+ const model = commit(new WatchModel(), {
251+ configuration: configuration({ favorites: [FIRST, SECOND, THIRD] })
252+ });
253+ assert.equal(model.moveSelection(1), true);
254+
255+ const updatedSecond = {
256+ ...SECOND,
257+ stopLabel: "Nouvel arrêt",
258+ sortOrder: 700
259+ };
260+ commit(model, {
261+ configuration: configuration({
262+ favorites: [THIRD, updatedSecond, FIRST]
263+ })
264+ });
265+ assert.deepEqual(model.snapshot(NOW_MS).activeFavorite, updatedSecond);
266+
267+ commit(model, {
268+ configuration: configuration({ favorites: [THIRD, FIRST] })
269+ });
270+ assert.deepEqual(model.snapshot(NOW_MS).activeFavorite, THIRD);
271+
272+ commit(model, {
273+ configuration: configuration({ favorites: [] })
274+ });
275+ assert.equal(model.snapshot(NOW_MS).activeFavorite, null);
276+});
277+
278+test("beginRequest accepts only configured active-favorite consultation triggers", () => {
279+ const unavailableModels = [
280+ new WatchModel(),
281+ commit(new WatchModel(), {
282+ configuration: configuration({ keyStatus: KEY_STATUS.MISSING })
283+ }),
284+ commit(new WatchModel(), {
285+ configuration: configuration({ keyStatus: KEY_STATUS.INVALID })
286+ }),
287+ commit(new WatchModel(), {
288+ configuration: configuration({ favorites: [] })
289+ })
290+ ];
291+ for (const model of unavailableModels) {
292+ assert.equal(model.beginRequest("rejected", REQUEST_TRIGGER.APP_OPEN), null);
98 }293 }
99 294
100- render(snapshot) {295+ const model = commit(new WatchModel());
101- this.renders.push(snapshot);296+ const triggers = [
297+ REQUEST_TRIGGER.APP_OPEN,
298+ REQUEST_TRIGGER.FAVORITE_SELECTION,
299+ REQUEST_TRIGGER.MANUAL_SELECT
300+ ];
301+ for (let index = 0; index < triggers.length; index += 1) {
302+ const requestId = "accepted-" + index;
303+ assert.deepEqual(model.beginRequest(requestId, triggers[index]), {
304+ requestId,
305+ favoriteId: FIRST.id,
306+ trigger: triggers[index]
307+ });
308+ model.cancelRequest();
102 }309 }
103 310
104- close() {}311+ for (const trigger of [-1, 3, "APP_OPEN", null, undefined]) {
105-}312+ assert.equal(model.beginRequest("invalid-trigger", trigger), null);
313+ }
314+});
106 315
107-function harness() {316+test("pending requests suppress duplicates until a complete active result exists", () => {
108- const queue = new FakeQueue();317+ const loading = commit(new WatchModel());
109- const view = new FakeView();318+ const first = loading.beginRequest("pending-1", REQUEST_TRIGGER.APP_OPEN);
110- const controller = createController({319+ assert.equal(
111- clock: { now: () => 1_788_000_000_000 },320+ loading.beginRequest("pending-2", REQUEST_TRIGGER.FAVORITE_SELECTION),
112- queue,321+ null
113- model: new WatchModel(),322+ );
114- view323+ assert.deepEqual(loading.snapshot(NOW_MS).expectedRequest, first);
115- }).start();
116- return { queue, view, controller };
117-}
118 324
119-test("foundation model exposes the first committed favorite and one fixture request", () => {325+ const ready = commit(new WatchModel(), {
120- const model = new WatchModel();326+ result: result(),
121- assert.deepEqual(model.commitProtocol({327+ event: "RESULT_COMMITTED"
122- configuration: {328+ });
123- keyStatus: KEY_STATUS.MISSING,329+ ready.beginRequest("refresh-1", REQUEST_TRIGGER.FAVORITE_SELECTION);
124- language: "en",330+ assert.deepEqual(
125- favorites: [FAVORITE]331+ ready.beginRequest("refresh-2", REQUEST_TRIGGER.MANUAL_SELECT),
126- },332+ {
127- result: null,333+ requestId: "refresh-2",
128- error: null334+ favoriteId: FIRST.id,
129- }, "CONFIG_COMMITTED"), { requestFixture: false });335+ trigger: REQUEST_TRIGGER.MANUAL_SELECT
130- assert.equal(model.beginFixtureRequest("missing-key"), null);336+ }
131-337+ );
132- const effect = model.commitProtocol({338+ const snapshot = ready.snapshot(NOW_MS);
133- configuration: {339+ assert.equal(snapshot.state, WATCH_STATE.READY);
134- keyStatus: KEY_STATUS.CONFIGURED,340+ assert.equal(snapshot.result.requestId, "result-1");
135- language: "fr",341+ assert.equal(snapshot.expectedRequest.requestId, "refresh-2");
136- favorites: [FAVORITE]
137- },
138- result: null,
139- error: null
140- }, "CONFIG_COMMITTED");
141-
142- assert.deepEqual(effect, { requestFixture: true });
143- const request = model.beginFixtureRequest("foundation-1");
144- assert.deepEqual(request, {
145- requestId: "foundation-1",
146- favoriteId: FAVORITE.id,
147- trigger: REQUEST_TRIGGER.APP_OPEN
148- });
149- assert.equal(model.beginFixtureRequest("foundation-duplicate"), null);
150- assert.equal(model.snapshot().favorite.id, FAVORITE.id);
151- assert.equal(model.snapshot().language, "fr");
152 });342 });
153 343
154-test("controller performs one APP_OPEN fixture round trip and preserves the committed result", () => {344+test("snapshot derives countdowns from expectedAt without changing wire data", () => {
155- const target = harness();345+ const received = result({
156- configurationMessages("config-1").forEach(target.controller.onReadable);346+ sourceUpdatedAt: NOW_SECONDS - 10,
157-347+ departures: [
158- assert.equal(target.queue.messages.length, 1);348+ departure({
159- assert.equal(target.queue.messages[0].get("REQUEST_TRIGGER"), REQUEST_TRIGGER.APP_OPEN);349+ expectedAt: NOW_SECONDS + 61,
160- const requestId = target.queue.messages[0].get("REQUEST_ID");350+ minutes: 44,
161- resultMessages(requestId).forEach(target.controller.onReadable);351+ aimedAt: NOW_SECONDS + 40
162-352+ }),
163- const committed = target.view.renders.at(-1);353+ departure({
164- assert.equal(committed.favorite.id, FAVORITE.id);354+ expectedAt: NOW_SECONDS,
165- assert.equal(committed.result.favoriteId, FAVORITE.id);355+ minutes: 27,
166- assert.equal(committed.result.departures[0].minutes, 2);356+ status: DEPARTURE_STATUS[1],
167-357+ nextIntervalMinutes: 4
168- const rejected = target.controller.onReadable(new Map([358+ }),
169- ["SCHEMA_VERSION", SCHEMA_VERSION + 1],359+ departure({
170- ["MESSAGE_TYPE", MESSAGE_TYPE.CONFIG_BEGIN]360+ expectedAt: NOW_SECONDS - 120,
171- ]));361+ minutes: -2,
172- assert.equal(rejected, RECEIVE_RESULT.REJECTED);362+ status: DEPARTURE_STATUS[2]
173- assert.equal(target.view.renders.at(-1), committed);363+ }),
174-364+ departure({
175- configurationMessages("config-2").forEach(target.controller.onReadable);365+ expectedAt: NOW_SECONDS - 61,
176- assert.equal(target.queue.messages.length, 1);366+ minutes: 8,
367+ status: DEPARTURE_STATUS[3]
368+ })
369+ ]
370+ });
371+ const original = structuredClone(received);
372+ const model = commit(new WatchModel(), {
373+ result: received,
374+ event: "RESULT_COMMITTED"
375+ });
376+
377+ const snapshot = model.snapshot(NOW_MS);
378+ assert.equal(snapshot.nowMs, NOW_MS);
379+ assert.deepEqual(
380+ snapshot.result.departures.map((item) => item.minutes),
381+ [44, 27, -2, 8]
382+ );
383+ assert.deepEqual(
384+ snapshot.result.departures.map((item) => item.countdownMinutes),
385+ [2, 0, null, -1]
386+ );
387+ assert.equal(snapshot.result.departures[2].status, DEPARTURE_STATUS[2]);
388+ assert.deepEqual(received, original);
389+
390+ snapshot.result.departures[0].minutes = -100;
391+ const oneMinuteLater = model.snapshot(NOW_MS + 60_000);
392+ assert.equal(oneMinuteLater.result.departures[0].minutes, 44);
393+ assert.equal(oneMinuteLater.result.departures[0].countdownMinutes, 1);
394+ assert.equal(oneMinuteLater.result.departures[1].countdownMinutes, -1);
395+ assert.equal(oneMinuteLater.result.departures[2].countdownMinutes, null);
396+ assert.deepEqual(received, original);
177 });397 });
added packages/watch/test/presentation.test.js +527 -0
new file mode 100644
@@ -0,0 +1,527 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import { registerHooks } from "node:module";
4+import {
5+ DEPARTURE_STATUS,
6+ ERROR_CODE,
7+ FRESHNESS
8+} from "../src/embeddedjs/contracts.js";
9+import { WATCH_STATE } from "../src/embeddedjs/model.js";
10+import { createPresentation } from "../src/embeddedjs/presentation.js";
11+
12+registerHooks({
13+ resolve(specifier, context, nextResolve) {
14+ if (specifier === "piu/MC") {
15+ return {
16+ shortCircuit: true,
17+ url: "data:text/javascript,export {}"
18+ };
19+ }
20+ return nextResolve(specifier, context);
21+ }
22+});
23+
24+globalThis.Behavior = class {};
25+globalThis.Style = class {
26+ constructor(options) {
27+ Object.assign(this, options);
28+ }
29+};
30+globalThis.Port = class {
31+ constructor(_, options) {
32+ this.behavior = new options.Behavior();
33+ this.behavior.onCreate();
34+ }
35+
36+ invalidate() {}
37+};
38+globalThis.Application = class {
39+ constructor(_, options) {
40+ Object.assign(this, options);
41+ }
42+
43+ empty() {
44+ this.contents = [];
45+ }
46+};
47+
48+const { createWatchView } = await import("../src/embeddedjs/ui.js");
49+
50+function localSeconds(hours, minutes) {
51+ return Math.floor(new Date(2026, 8, 4, hours, minutes, 0, 0).getTime() / 1000);
52+}
53+
54+const FETCHED_AT = localSeconds(15, 5);
55+const EXPECTED_COPY = {
56+ en: {
57+ unconfigured: "Open phone options\nAdd a PRIM key and favorite",
58+ loading: "Loading departures…",
59+ apiKeyInvalid: "PRIM key rejected\nOpen phone options",
60+ favoriteUnavailable: "Favorite unavailable\nOpen phone options",
61+ rateLimited: "Too many requests\nTry again later",
62+ departuresUnavailable: "Departures unavailable\nSelect to retry",
63+ noDepartures: "No departures",
64+ freshRealtime: "Live",
65+ freshScheduled: "Scheduled",
66+ freshMixed: "Live + schedule",
67+ freshStale: "Out of date",
68+ updated: "Updated"
69+ },
70+ fr: {
71+ unconfigured: "Ouvrez les options téléphone\nAjoutez une clé PRIM et un favori",
72+ loading: "Chargement des départs…",
73+ apiKeyInvalid: "Clé PRIM refusée\nOuvrez les options téléphone",
74+ favoriteUnavailable: "Favori indisponible\nOuvrez les options téléphone",
75+ rateLimited: "Trop de demandes\nRéessayez plus tard",
76+ departuresUnavailable: "Départs indisponibles\nSélection pour réessayer",
77+ noDepartures: "Aucun départ",
78+ freshRealtime: "Temps réel",
79+ freshScheduled: "Horaires",
80+ freshMixed: "Temps réel + horaires",
81+ freshStale: "Périmé",
82+ updated: "Mis à jour"
83+ }
84+};
85+
86+function favorite(overrides = {}) {
87+ return {
88+ id: "home",
89+ serviceId: "opaque:service:home",
90+ stopLabel: "Saint-Michel Notre-Dame",
91+ lineLabel: "RER B",
92+ destinationLabel: "Aéroport Charles-de-Gaulle 2 TGV",
93+ sortOrder: 0,
94+ displayName: "Bureau – côté Seine",
95+ ...overrides
96+ };
97+}
98+
99+function departure(overrides = {}) {
100+ return {
101+ expectedAt: FETCHED_AT + 120,
102+ aimedAt: FETCHED_AT + 60,
103+ minutes: 999,
104+ countdownMinutes: 2,
105+ status: DEPARTURE_STATUS[0],
106+ ...overrides
107+ };
108+}
109+
110+function result(overrides = {}) {
111+ return {
112+ requestId: "result-1",
113+ favoriteId: "home",
114+ fetchedAt: FETCHED_AT,
115+ sourceUpdatedAt: FETCHED_AT - 10,
116+ freshness: FRESHNESS[0],
117+ departures: [departure()],
118+ ...overrides
119+ };
120+}
121+
122+function snapshot(overrides = {}) {
123+ return {
124+ state: WATCH_STATE.READY,
125+ language: "en",
126+ nowMs: FETCHED_AT * 1000,
127+ activeFavorite: favorite(),
128+ result: result(),
129+ error: null,
130+ sendFailed: false,
131+ expectedRequest: null,
132+ ...overrides
133+ };
134+}
135+
136+function present(value, screenInfo = {}) {
137+ return createPresentation(value, {
138+ width: 200,
139+ height: 228,
140+ round: false,
141+ hour12: false,
142+ ...screenInfo
143+ });
144+}
145+
146+test("the five consultation states select result, loading, and critical content", () => {
147+ const unconfigured = present(snapshot({
148+ state: WATCH_STATE.UNCONFIGURED,
149+ activeFavorite: null,
150+ result: null
151+ }));
152+ assert.equal(unconfigured.stateMessage, EXPECTED_COPY.en.unconfigured);
153+ assert.deepEqual(unconfigured.rows, []);
154+ assert.equal(unconfigured.footer, "");
155+
156+ const loading = present(snapshot({
157+ state: WATCH_STATE.LOADING,
158+ language: "fr",
159+ result: null
160+ }));
161+ assert.equal(loading.stateMessage, EXPECTED_COPY.fr.loading);
162+ assert.deepEqual(loading.rows, []);
163+ assert.equal(loading.footer, "");
164+
165+ const ready = present(snapshot());
166+ assert.equal(ready.stateMessage, "");
167+ assert.deepEqual(ready.rows, ["2 min"]);
168+ assert.equal(ready.footer, "Live · Updated 15:05");
169+
170+ const stale = present(snapshot({
171+ state: WATCH_STATE.STALE,
172+ error: {
173+ requestId: "failed-refresh",
174+ favoriteId: "home",
175+ code: ERROR_CODE[3],
176+ occurredAt: FETCHED_AT
177+ }
178+ }));
179+ assert.equal(stale.stateMessage, "");
180+ assert.deepEqual(stale.rows, ["2 min"]);
181+ assert.equal(stale.footer, "Out of date · Updated 15:05");
182+
183+ const unavailable = present(snapshot({
184+ state: WATCH_STATE.UNAVAILABLE,
185+ result: null,
186+ error: {
187+ requestId: "failed-load",
188+ favoriteId: "home",
189+ code: ERROR_CODE[3],
190+ occurredAt: FETCHED_AT
191+ }
192+ }));
193+ assert.equal(unavailable.stateMessage, EXPECTED_COPY.en.departuresUnavailable);
194+ assert.deepEqual(unavailable.rows, []);
195+ assert.equal(unavailable.footer, "");
196+});
197+
198+test("every frozen error code maps to the authored critical message", () => {
199+ const scenarios = [
200+ { state: WATCH_STATE.UNCONFIGURED, code: ERROR_CODE[0], copyId: "unconfigured" },
201+ { state: WATCH_STATE.UNAVAILABLE, code: ERROR_CODE[1], copyId: "apiKeyInvalid" },
202+ { state: WATCH_STATE.UNAVAILABLE, code: ERROR_CODE[2], copyId: "favoriteUnavailable" },
203+ { state: WATCH_STATE.UNAVAILABLE, code: ERROR_CODE[3], copyId: "departuresUnavailable" },
204+ { state: WATCH_STATE.UNAVAILABLE, code: ERROR_CODE[4], copyId: "rateLimited" },
205+ { state: WATCH_STATE.UNAVAILABLE, code: ERROR_CODE[5], copyId: "departuresUnavailable" }
206+ ];
207+
208+ for (const language of ["en", "fr"]) {
209+ for (const scenario of scenarios) {
210+ const presentation = present(snapshot({
211+ state: scenario.state,
212+ language,
213+ result: null,
214+ error: {
215+ requestId: "error-" + scenario.code,
216+ favoriteId: "home",
217+ code: scenario.code,
218+ occurredAt: FETCHED_AT
219+ }
220+ }));
221+ assert.equal(presentation.stateMessage, EXPECTED_COPY[language][scenario.copyId]);
222+ assert.deepEqual(presentation.rows, []);
223+ assert.equal(presentation.footer, "");
224+ }
225+
226+ assert.equal(present(snapshot({
227+ state: WATCH_STATE.UNAVAILABLE,
228+ language,
229+ result: null,
230+ error: null
231+ })).stateMessage, EXPECTED_COPY[language].apiKeyInvalid);
232+ assert.equal(present(snapshot({
233+ state: WATCH_STATE.UNAVAILABLE,
234+ language,
235+ result: null,
236+ error: null,
237+ sendFailed: true
238+ })).stateMessage, EXPECTED_COPY[language].departuresUnavailable);
239+ }
240+});
241+
242+test("language fallback and maximum labels remain verbatim without mutating inputs", () => {
243+ const displayName = "é".repeat(48);
244+ const stopLabel = "S".repeat(96);
245+ const lineLabel = "L".repeat(96);
246+ const destinationLabel = "D".repeat(96);
247+ const value = snapshot({
248+ state: WATCH_STATE.LOADING,
249+ language: "de-DE",
250+ activeFavorite: favorite({
251+ displayName,
252+ stopLabel,
253+ lineLabel,
254+ destinationLabel
255+ }),
256+ result: null
257+ });
258+ const screenInfo = {
259+ width: 200,
260+ height: 228,
261+ round: false,
262+ hour12: false
263+ };
264+ const originalValue = structuredClone(value);
265+ const originalScreen = { ...screenInfo };
266+ const presentation = createPresentation(value, screenInfo);
267+
268+ assert.equal(new TextEncoder().encode(displayName).length, 96);
269+ assert.equal(presentation.favoriteLabel, displayName);
270+ assert.equal(presentation.header, lineLabel + "\n" + destinationLabel);
271+ assert.equal(presentation.stateMessage, EXPECTED_COPY.en.loading);
272+ assert.deepEqual(value, originalValue);
273+ assert.deepEqual(screenInfo, originalScreen);
274+
275+ const fallback = present(snapshot({
276+ state: WATCH_STATE.LOADING,
277+ language: "fr-CA",
278+ activeFavorite: favorite({ displayName: "", stopLabel }),
279+ result: null
280+ }));
281+ assert.equal(fallback.favoriteLabel, stopLabel);
282+ assert.equal(fallback.stateMessage, EXPECTED_COPY.fr.loading);
283+});
284+
285+test("emery shows three rows and round gabbro shows two formatted rows", () => {
286+ const value = snapshot({
287+ result: result({
288+ sourceUpdatedAt: 123,
289+ departures: [
290+ departure({ aimedAt: 456, minutes: 88, countdownMinutes: 0 }),
291+ departure({
292+ minutes: -50,
293+ countdownMinutes: 12,
294+ status: DEPARTURE_STATUS[1],
295+ nextIntervalMinutes: 5
296+ }),
297+ departure({
298+ minutes: 45,
299+ countdownMinutes: null,
300+ status: DEPARTURE_STATUS[2],
301+ nextIntervalMinutes: 9
302+ }),
303+ departure({ minutes: 0, countdownMinutes: -1 })
304+ ]
305+ })
306+ });
307+ const emery = present(value);
308+ const gabbro = present(value, {
309+ width: 180,
310+ height: 180,
311+ round: true
312+ });
313+
314+ assert.deepEqual(Object.keys(emery).sort(), [
315+ "favoriteLabel",
316+ "footer",
317+ "header",
318+ "rowLimit",
319+ "rows",
320+ "stateMessage"
321+ ]);
322+ assert.equal(emery.rowLimit, 3);
323+ assert.deepEqual(emery.rows, [
324+ "Now",
325+ "12 min · Delayed · then 5 min",
326+ "Cancelled"
327+ ]);
328+ assert.equal(gabbro.rowLimit, 2);
329+ assert.deepEqual(gabbro.rows, emery.rows.slice(0, 2));
330+ assert.equal(gabbro.favoriteLabel, emery.favoriteLabel);
331+ assert.equal(gabbro.header, emery.header);
332+ assert.equal(gabbro.stateMessage, emery.stateMessage);
333+ assert.equal(gabbro.footer, emery.footer);
334+
335+ const french = present(snapshot({
336+ language: "fr",
337+ result: result({
338+ departures: [
339+ departure({
340+ countdownMinutes: -1,
341+ status: DEPARTURE_STATUS[1],
342+ nextIntervalMinutes: 0
343+ }),
344+ departure({
345+ countdownMinutes: null,
346+ status: DEPARTURE_STATUS[2],
347+ nextIntervalMinutes: 4
348+ })
349+ ]
350+ })
351+ }), { round: true });
352+ assert.deepEqual(french.rows, [
353+ "Parti · Retardé · puis 0 min",
354+ "Annulé"
355+ ]);
356+});
357+
358+test("derived countdown text changes at minute boundaries and ignores wire minutes", () => {
359+ const expectedAtMs = (FETCHED_AT + 600) * 1000;
360+ function rowAt(offsetMs) {
361+ const nowMs = expectedAtMs + offsetMs;
362+ const countdownMinutes = Math.ceil((expectedAtMs - nowMs) / 60000);
363+ return present(snapshot({
364+ nowMs,
365+ result: result({
366+ departures: [departure({
367+ expectedAt: expectedAtMs / 1000,
368+ minutes: 777,
369+ countdownMinutes
370+ })]
371+ })
372+ })).rows[0];
373+ }
374+
375+ assert.equal(rowAt(-60_001), "2 min");
376+ assert.equal(rowAt(-60_000), "1 min");
377+ assert.equal(rowAt(0), "Now");
378+ assert.equal(rowAt(1), "Now");
379+ assert.equal(rowAt(60_000), "Departed");
380+});
381+
382+test("every freshness label and 12/24-hour refresh time is localized", () => {
383+ const freshnessCases = [
384+ { freshness: FRESHNESS[0], copyId: "freshRealtime" },
385+ { freshness: FRESHNESS[1], copyId: "freshScheduled" },
386+ { freshness: FRESHNESS[2], copyId: "freshMixed" },
387+ { freshness: FRESHNESS[3], copyId: "freshStale" }
388+ ];
389+
390+ for (const language of ["en", "fr"]) {
391+ for (const scenario of freshnessCases) {
392+ const presentation = present(snapshot({
393+ state: scenario.freshness === FRESHNESS[3]
394+ ? WATCH_STATE.STALE
395+ : WATCH_STATE.READY,
396+ language,
397+ result: result({ freshness: scenario.freshness })
398+ }));
399+ assert.equal(
400+ presentation.footer,
401+ EXPECTED_COPY[language][scenario.copyId]
402+ + " · " + EXPECTED_COPY[language].updated + " 15:05"
403+ );
404+ }
405+ }
406+
407+ const twelveHour = present(snapshot(), { hour12: true });
408+ const twentyFourHour = present(snapshot(), { hour12: false });
409+ assert.equal(twelveHour.footer, "Live · Updated 03:05");
410+ assert.equal(twentyFourHour.footer, "Live · Updated 15:05");
411+ assert.deepEqual(
412+ { ...twelveHour, footer: "" },
413+ { ...twentyFourHour, footer: "" }
414+ );
415+
416+ const midnight = localSeconds(0, 7);
417+ assert.equal(present(snapshot({
418+ result: result({ fetchedAt: midnight })
419+ }), { hour12: true }).footer, "Live · Updated 12:07");
420+ assert.equal(present(snapshot({
421+ result: result({ fetchedAt: midnight })
422+ }), { hour12: false }).footer, "Live · Updated 00:07");
423+});
424+
425+test("empty results and every critical copy stay within authored line bounds", () => {
426+ const empty = present(snapshot({
427+ language: "fr",
428+ result: result({ departures: [] })
429+ }));
430+ assert.equal(empty.stateMessage, EXPECTED_COPY.fr.noDepartures);
431+ assert.deepEqual(empty.rows, []);
432+ assert.equal(empty.footer, "Temps réel · Mis à jour 15:05");
433+
434+ const criticalIds = [
435+ "unconfigured",
436+ "loading",
437+ "apiKeyInvalid",
438+ "favoriteUnavailable",
439+ "rateLimited",
440+ "departuresUnavailable"
441+ ];
442+ for (const language of ["en", "fr"]) {
443+ for (const copyId of criticalIds) {
444+ const lines = EXPECTED_COPY[language][copyId].split("\n");
445+ assert.equal(lines.length <= 2, true, language + ":" + copyId);
446+ assert.equal(
447+ lines.every((line) => Array.from(line).length <= 33),
448+ true,
449+ language + ":" + copyId
450+ );
451+ }
452+ }
453+});
454+
455+function drawWatchFrame(screenInfo, activeFavorite) {
456+ const view = createWatchView(screenInfo);
457+ const port = view.application.contents[0];
458+ const draws = [];
459+ const drawingPort = {
460+ width: screenInfo.width,
461+ height: screenInfo.height,
462+ fillColor() {},
463+ pushClip() {},
464+ popClip() {},
465+ measureString(string) {
466+ let width = 0;
467+ for (const character of string) {
468+ if (character === "W") width += 9;
469+ else if (character === "i") width += 3;
470+ else if (character === "…") width += 5;
471+ else width += 7;
472+ }
473+ return { width };
474+ },
475+ drawStyle(string, style, x, y, width, height) {
476+ draws.push({
477+ string,
478+ x,
479+ y,
480+ width,
481+ height,
482+ measuredWidth: this.measureString(string, style).width
483+ });
484+ }
485+ };
486+ view.render(snapshot({ activeFavorite }));
487+ port.behavior.onDraw(drawingPort);
488+ view.releasePresentation();
489+ return draws;
490+}
491+
492+test("route drawing reserves one pixel-bounded line for each contract-max label", () => {
493+ const platforms = [
494+ { width: 200, height: 228, round: false },
495+ { width: 180, height: 180, round: true }
496+ ];
497+ const lineLabel = "W".repeat(96);
498+ const destinationLabel = "i".repeat(96);
499+
500+ for (const screenInfo of platforms) {
501+ const longDraws = drawWatchFrame(screenInfo, favorite({
502+ displayName: "Desk",
503+ lineLabel,
504+ destinationLabel
505+ }));
506+ const lineDraw = longDraws.find((draw) => draw.string.startsWith("W"));
507+ const destinationDraw = longDraws.find((draw) => draw.string.startsWith("i"));
508+
509+ assert.ok(lineDraw, "line label is visibly represented");
510+ assert.ok(destinationDraw, "destination label is visibly represented");
511+ assert.equal(lineDraw.string.endsWith("…"), true);
512+ assert.equal(destinationDraw.string.endsWith("…"), true);
513+ assert.equal(lineDraw.measuredWidth <= lineDraw.width, true);
514+ assert.equal(destinationDraw.measuredWidth <= destinationDraw.width, true);
515+ assert.equal(lineDraw.height, 16);
516+ assert.equal(destinationDraw.height, 16);
517+ assert.equal(destinationDraw.y - lineDraw.y, 16);
518+
519+ const normalDraws = drawWatchFrame(screenInfo, favorite({
520+ displayName: "Desk",
521+ lineLabel: "RER B",
522+ destinationLabel: "Paris"
523+ }));
524+ assert.ok(normalDraws.some((draw) => draw.string === "RER B"));
525+ assert.ok(normalDraws.some((draw) => draw.string === "Paris"));
526+ }
527+});
new file mode 100644
@@ -0,0 +1,527 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import { registerHooks } from "node:module";
4+import {
5+ DEPARTURE_STATUS,
6+ ERROR_CODE,
7+ FRESHNESS
8+} from "../src/embeddedjs/contracts.js";
9+import { WATCH_STATE } from "../src/embeddedjs/model.js";
10+import { createPresentation } from "../src/embeddedjs/presentation.js";
11+
12+registerHooks({
13+ resolve(specifier, context, nextResolve) {
14+ if (specifier === "piu/MC") {
15+ return {
16+ shortCircuit: true,
17+ url: "data:text/javascript,export {}"
18+ };
19+ }
20+ return nextResolve(specifier, context);
21+ }
22+});
23+
24+globalThis.Behavior = class {};
25+globalThis.Style = class {
26+ constructor(options) {
27+ Object.assign(this, options);
28+ }
29+};
30+globalThis.Port = class {
31+ constructor(_, options) {
32+ this.behavior = new options.Behavior();
33+ this.behavior.onCreate();
34+ }
35+
36+ invalidate() {}
37+};
38+globalThis.Application = class {
39+ constructor(_, options) {
40+ Object.assign(this, options);
41+ }
42+
43+ empty() {
44+ this.contents = [];
45+ }
46+};
47+
48+const { createWatchView } = await import("../src/embeddedjs/ui.js");
49+
50+function localSeconds(hours, minutes) {
51+ return Math.floor(new Date(2026, 8, 4, hours, minutes, 0, 0).getTime() / 1000);
52+}
53+
54+const FETCHED_AT = localSeconds(15, 5);
55+const EXPECTED_COPY = {
56+ en: {
57+ unconfigured: "Open phone options\nAdd a PRIM key and favorite",
58+ loading: "Loading departures…",
59+ apiKeyInvalid: "PRIM key rejected\nOpen phone options",
60+ favoriteUnavailable: "Favorite unavailable\nOpen phone options",
61+ rateLimited: "Too many requests\nTry again later",
62+ departuresUnavailable: "Departures unavailable\nSelect to retry",
63+ noDepartures: "No departures",
64+ freshRealtime: "Live",
65+ freshScheduled: "Scheduled",
66+ freshMixed: "Live + schedule",
67+ freshStale: "Out of date",
68+ updated: "Updated"
69+ },
70+ fr: {
71+ unconfigured: "Ouvrez les options téléphone\nAjoutez une clé PRIM et un favori",
72+ loading: "Chargement des départs…",
73+ apiKeyInvalid: "Clé PRIM refusée\nOuvrez les options téléphone",
74+ favoriteUnavailable: "Favori indisponible\nOuvrez les options téléphone",
75+ rateLimited: "Trop de demandes\nRéessayez plus tard",
76+ departuresUnavailable: "Départs indisponibles\nSélection pour réessayer",
77+ noDepartures: "Aucun départ",
78+ freshRealtime: "Temps réel",
79+ freshScheduled: "Horaires",
80+ freshMixed: "Temps réel + horaires",
81+ freshStale: "Périmé",
82+ updated: "Mis à jour"
83+ }
84+};
85+
86+function favorite(overrides = {}) {
87+ return {
88+ id: "home",
89+ serviceId: "opaque:service:home",
90+ stopLabel: "Saint-Michel Notre-Dame",
91+ lineLabel: "RER B",
92+ destinationLabel: "Aéroport Charles-de-Gaulle 2 TGV",
93+ sortOrder: 0,
94+ displayName: "Bureau – côté Seine",
95+ ...overrides
96+ };
97+}
98+
99+function departure(overrides = {}) {
100+ return {
101+ expectedAt: FETCHED_AT + 120,
102+ aimedAt: FETCHED_AT + 60,
103+ minutes: 999,
104+ countdownMinutes: 2,
105+ status: DEPARTURE_STATUS[0],
106+ ...overrides
107+ };
108+}
109+
110+function result(overrides = {}) {
111+ return {
112+ requestId: "result-1",
113+ favoriteId: "home",
114+ fetchedAt: FETCHED_AT,
115+ sourceUpdatedAt: FETCHED_AT - 10,
116+ freshness: FRESHNESS[0],
117+ departures: [departure()],
118+ ...overrides
119+ };
120+}
121+
122+function snapshot(overrides = {}) {
123+ return {
124+ state: WATCH_STATE.READY,
125+ language: "en",
126+ nowMs: FETCHED_AT * 1000,
127+ activeFavorite: favorite(),
128+ result: result(),
129+ error: null,
130+ sendFailed: false,
131+ expectedRequest: null,
132+ ...overrides
133+ };
134+}
135+
136+function present(value, screenInfo = {}) {
137+ return createPresentation(value, {
138+ width: 200,
139+ height: 228,
140+ round: false,
141+ hour12: false,
142+ ...screenInfo
143+ });
144+}
145+
146+test("the five consultation states select result, loading, and critical content", () => {
147+ const unconfigured = present(snapshot({
148+ state: WATCH_STATE.UNCONFIGURED,
149+ activeFavorite: null,
150+ result: null
151+ }));
152+ assert.equal(unconfigured.stateMessage, EXPECTED_COPY.en.unconfigured);
153+ assert.deepEqual(unconfigured.rows, []);
154+ assert.equal(unconfigured.footer, "");
155+
156+ const loading = present(snapshot({
157+ state: WATCH_STATE.LOADING,
158+ language: "fr",
159+ result: null
160+ }));
161+ assert.equal(loading.stateMessage, EXPECTED_COPY.fr.loading);
162+ assert.deepEqual(loading.rows, []);
163+ assert.equal(loading.footer, "");
164+
165+ const ready = present(snapshot());
166+ assert.equal(ready.stateMessage, "");
167+ assert.deepEqual(ready.rows, ["2 min"]);
168+ assert.equal(ready.footer, "Live · Updated 15:05");
169+
170+ const stale = present(snapshot({
171+ state: WATCH_STATE.STALE,
172+ error: {
173+ requestId: "failed-refresh",
174+ favoriteId: "home",
175+ code: ERROR_CODE[3],
176+ occurredAt: FETCHED_AT
177+ }
178+ }));
179+ assert.equal(stale.stateMessage, "");
180+ assert.deepEqual(stale.rows, ["2 min"]);
181+ assert.equal(stale.footer, "Out of date · Updated 15:05");
182+
183+ const unavailable = present(snapshot({
184+ state: WATCH_STATE.UNAVAILABLE,
185+ result: null,
186+ error: {
187+ requestId: "failed-load",
188+ favoriteId: "home",
189+ code: ERROR_CODE[3],
190+ occurredAt: FETCHED_AT
191+ }
192+ }));
193+ assert.equal(unavailable.stateMessage, EXPECTED_COPY.en.departuresUnavailable);
194+ assert.deepEqual(unavailable.rows, []);
195+ assert.equal(unavailable.footer, "");
196+});
197+
198+test("every frozen error code maps to the authored critical message", () => {
199+ const scenarios = [
200+ { state: WATCH_STATE.UNCONFIGURED, code: ERROR_CODE[0], copyId: "unconfigured" },
201+ { state: WATCH_STATE.UNAVAILABLE, code: ERROR_CODE[1], copyId: "apiKeyInvalid" },
202+ { state: WATCH_STATE.UNAVAILABLE, code: ERROR_CODE[2], copyId: "favoriteUnavailable" },
203+ { state: WATCH_STATE.UNAVAILABLE, code: ERROR_CODE[3], copyId: "departuresUnavailable" },
204+ { state: WATCH_STATE.UNAVAILABLE, code: ERROR_CODE[4], copyId: "rateLimited" },
205+ { state: WATCH_STATE.UNAVAILABLE, code: ERROR_CODE[5], copyId: "departuresUnavailable" }
206+ ];
207+
208+ for (const language of ["en", "fr"]) {
209+ for (const scenario of scenarios) {
210+ const presentation = present(snapshot({
211+ state: scenario.state,
212+ language,
213+ result: null,
214+ error: {
215+ requestId: "error-" + scenario.code,
216+ favoriteId: "home",
217+ code: scenario.code,
218+ occurredAt: FETCHED_AT
219+ }
220+ }));
221+ assert.equal(presentation.stateMessage, EXPECTED_COPY[language][scenario.copyId]);
222+ assert.deepEqual(presentation.rows, []);
223+ assert.equal(presentation.footer, "");
224+ }
225+
226+ assert.equal(present(snapshot({
227+ state: WATCH_STATE.UNAVAILABLE,
228+ language,
229+ result: null,
230+ error: null
231+ })).stateMessage, EXPECTED_COPY[language].apiKeyInvalid);
232+ assert.equal(present(snapshot({
233+ state: WATCH_STATE.UNAVAILABLE,
234+ language,
235+ result: null,
236+ error: null,
237+ sendFailed: true
238+ })).stateMessage, EXPECTED_COPY[language].departuresUnavailable);
239+ }
240+});
241+
242+test("language fallback and maximum labels remain verbatim without mutating inputs", () => {
243+ const displayName = "é".repeat(48);
244+ const stopLabel = "S".repeat(96);
245+ const lineLabel = "L".repeat(96);
246+ const destinationLabel = "D".repeat(96);
247+ const value = snapshot({
248+ state: WATCH_STATE.LOADING,
249+ language: "de-DE",
250+ activeFavorite: favorite({
251+ displayName,
252+ stopLabel,
253+ lineLabel,
254+ destinationLabel
255+ }),
256+ result: null
257+ });
258+ const screenInfo = {
259+ width: 200,
260+ height: 228,
261+ round: false,
262+ hour12: false
263+ };
264+ const originalValue = structuredClone(value);
265+ const originalScreen = { ...screenInfo };
266+ const presentation = createPresentation(value, screenInfo);
267+
268+ assert.equal(new TextEncoder().encode(displayName).length, 96);
269+ assert.equal(presentation.favoriteLabel, displayName);
270+ assert.equal(presentation.header, lineLabel + "\n" + destinationLabel);
271+ assert.equal(presentation.stateMessage, EXPECTED_COPY.en.loading);
272+ assert.deepEqual(value, originalValue);
273+ assert.deepEqual(screenInfo, originalScreen);
274+
275+ const fallback = present(snapshot({
276+ state: WATCH_STATE.LOADING,
277+ language: "fr-CA",
278+ activeFavorite: favorite({ displayName: "", stopLabel }),
279+ result: null
280+ }));
281+ assert.equal(fallback.favoriteLabel, stopLabel);
282+ assert.equal(fallback.stateMessage, EXPECTED_COPY.fr.loading);
283+});
284+
285+test("emery shows three rows and round gabbro shows two formatted rows", () => {
286+ const value = snapshot({
287+ result: result({
288+ sourceUpdatedAt: 123,
289+ departures: [
290+ departure({ aimedAt: 456, minutes: 88, countdownMinutes: 0 }),
291+ departure({
292+ minutes: -50,
293+ countdownMinutes: 12,
294+ status: DEPARTURE_STATUS[1],
295+ nextIntervalMinutes: 5
296+ }),
297+ departure({
298+ minutes: 45,
299+ countdownMinutes: null,
300+ status: DEPARTURE_STATUS[2],
301+ nextIntervalMinutes: 9
302+ }),
303+ departure({ minutes: 0, countdownMinutes: -1 })
304+ ]
305+ })
306+ });
307+ const emery = present(value);
308+ const gabbro = present(value, {
309+ width: 180,
310+ height: 180,
311+ round: true
312+ });
313+
314+ assert.deepEqual(Object.keys(emery).sort(), [
315+ "favoriteLabel",
316+ "footer",
317+ "header",
318+ "rowLimit",
319+ "rows",
320+ "stateMessage"
321+ ]);
322+ assert.equal(emery.rowLimit, 3);
323+ assert.deepEqual(emery.rows, [
324+ "Now",
325+ "12 min · Delayed · then 5 min",
326+ "Cancelled"
327+ ]);
328+ assert.equal(gabbro.rowLimit, 2);
329+ assert.deepEqual(gabbro.rows, emery.rows.slice(0, 2));
330+ assert.equal(gabbro.favoriteLabel, emery.favoriteLabel);
331+ assert.equal(gabbro.header, emery.header);
332+ assert.equal(gabbro.stateMessage, emery.stateMessage);
333+ assert.equal(gabbro.footer, emery.footer);
334+
335+ const french = present(snapshot({
336+ language: "fr",
337+ result: result({
338+ departures: [
339+ departure({
340+ countdownMinutes: -1,
341+ status: DEPARTURE_STATUS[1],
342+ nextIntervalMinutes: 0
343+ }),
344+ departure({
345+ countdownMinutes: null,
346+ status: DEPARTURE_STATUS[2],
347+ nextIntervalMinutes: 4
348+ })
349+ ]
350+ })
351+ }), { round: true });
352+ assert.deepEqual(french.rows, [
353+ "Parti · Retardé · puis 0 min",
354+ "Annulé"
355+ ]);
356+});
357+
358+test("derived countdown text changes at minute boundaries and ignores wire minutes", () => {
359+ const expectedAtMs = (FETCHED_AT + 600) * 1000;
360+ function rowAt(offsetMs) {
361+ const nowMs = expectedAtMs + offsetMs;
362+ const countdownMinutes = Math.ceil((expectedAtMs - nowMs) / 60000);
363+ return present(snapshot({
364+ nowMs,
365+ result: result({
366+ departures: [departure({
367+ expectedAt: expectedAtMs / 1000,
368+ minutes: 777,
369+ countdownMinutes
370+ })]
371+ })
372+ })).rows[0];
373+ }
374+
375+ assert.equal(rowAt(-60_001), "2 min");
376+ assert.equal(rowAt(-60_000), "1 min");
377+ assert.equal(rowAt(0), "Now");
378+ assert.equal(rowAt(1), "Now");
379+ assert.equal(rowAt(60_000), "Departed");
380+});
381+
382+test("every freshness label and 12/24-hour refresh time is localized", () => {
383+ const freshnessCases = [
384+ { freshness: FRESHNESS[0], copyId: "freshRealtime" },
385+ { freshness: FRESHNESS[1], copyId: "freshScheduled" },
386+ { freshness: FRESHNESS[2], copyId: "freshMixed" },
387+ { freshness: FRESHNESS[3], copyId: "freshStale" }
388+ ];
389+
390+ for (const language of ["en", "fr"]) {
391+ for (const scenario of freshnessCases) {
392+ const presentation = present(snapshot({
393+ state: scenario.freshness === FRESHNESS[3]
394+ ? WATCH_STATE.STALE
395+ : WATCH_STATE.READY,
396+ language,
397+ result: result({ freshness: scenario.freshness })
398+ }));
399+ assert.equal(
400+ presentation.footer,
401+ EXPECTED_COPY[language][scenario.copyId]
402+ + " · " + EXPECTED_COPY[language].updated + " 15:05"
403+ );
404+ }
405+ }
406+
407+ const twelveHour = present(snapshot(), { hour12: true });
408+ const twentyFourHour = present(snapshot(), { hour12: false });
409+ assert.equal(twelveHour.footer, "Live · Updated 03:05");
410+ assert.equal(twentyFourHour.footer, "Live · Updated 15:05");
411+ assert.deepEqual(
412+ { ...twelveHour, footer: "" },
413+ { ...twentyFourHour, footer: "" }
414+ );
415+
416+ const midnight = localSeconds(0, 7);
417+ assert.equal(present(snapshot({
418+ result: result({ fetchedAt: midnight })
419+ }), { hour12: true }).footer, "Live · Updated 12:07");
420+ assert.equal(present(snapshot({
421+ result: result({ fetchedAt: midnight })
422+ }), { hour12: false }).footer, "Live · Updated 00:07");
423+});
424+
425+test("empty results and every critical copy stay within authored line bounds", () => {
426+ const empty = present(snapshot({
427+ language: "fr",
428+ result: result({ departures: [] })
429+ }));
430+ assert.equal(empty.stateMessage, EXPECTED_COPY.fr.noDepartures);
431+ assert.deepEqual(empty.rows, []);
432+ assert.equal(empty.footer, "Temps réel · Mis à jour 15:05");
433+
434+ const criticalIds = [
435+ "unconfigured",
436+ "loading",
437+ "apiKeyInvalid",
438+ "favoriteUnavailable",
439+ "rateLimited",
440+ "departuresUnavailable"
441+ ];
442+ for (const language of ["en", "fr"]) {
443+ for (const copyId of criticalIds) {
444+ const lines = EXPECTED_COPY[language][copyId].split("\n");
445+ assert.equal(lines.length <= 2, true, language + ":" + copyId);
446+ assert.equal(
447+ lines.every((line) => Array.from(line).length <= 33),
448+ true,
449+ language + ":" + copyId
450+ );
451+ }
452+ }
453+});
454+
455+function drawWatchFrame(screenInfo, activeFavorite) {
456+ const view = createWatchView(screenInfo);
457+ const port = view.application.contents[0];
458+ const draws = [];
459+ const drawingPort = {
460+ width: screenInfo.width,
461+ height: screenInfo.height,
462+ fillColor() {},
463+ pushClip() {},
464+ popClip() {},
465+ measureString(string) {
466+ let width = 0;
467+ for (const character of string) {
468+ if (character === "W") width += 9;
469+ else if (character === "i") width += 3;
470+ else if (character === "…") width += 5;
471+ else width += 7;
472+ }
473+ return { width };
474+ },
475+ drawStyle(string, style, x, y, width, height) {
476+ draws.push({
477+ string,
478+ x,
479+ y,
480+ width,
481+ height,
482+ measuredWidth: this.measureString(string, style).width
483+ });
484+ }
485+ };
486+ view.render(snapshot({ activeFavorite }));
487+ port.behavior.onDraw(drawingPort);
488+ view.releasePresentation();
489+ return draws;
490+}
491+
492+test("route drawing reserves one pixel-bounded line for each contract-max label", () => {
493+ const platforms = [
494+ { width: 200, height: 228, round: false },
495+ { width: 180, height: 180, round: true }
496+ ];
497+ const lineLabel = "W".repeat(96);
498+ const destinationLabel = "i".repeat(96);
499+
500+ for (const screenInfo of platforms) {
501+ const longDraws = drawWatchFrame(screenInfo, favorite({
502+ displayName: "Desk",
503+ lineLabel,
504+ destinationLabel
505+ }));
506+ const lineDraw = longDraws.find((draw) => draw.string.startsWith("W"));
507+ const destinationDraw = longDraws.find((draw) => draw.string.startsWith("i"));
508+
509+ assert.ok(lineDraw, "line label is visibly represented");
510+ assert.ok(destinationDraw, "destination label is visibly represented");
511+ assert.equal(lineDraw.string.endsWith("…"), true);
512+ assert.equal(destinationDraw.string.endsWith("…"), true);
513+ assert.equal(lineDraw.measuredWidth <= lineDraw.width, true);
514+ assert.equal(destinationDraw.measuredWidth <= destinationDraw.width, true);
515+ assert.equal(lineDraw.height, 16);
516+ assert.equal(destinationDraw.height, 16);
517+ assert.equal(destinationDraw.y - lineDraw.y, 16);
518+
519+ const normalDraws = drawWatchFrame(screenInfo, favorite({
520+ displayName: "Desk",
521+ lineLabel: "RER B",
522+ destinationLabel: "Paris"
523+ }));
524+ assert.ok(normalDraws.some((draw) => draw.string === "RER B"));
525+ assert.ok(normalDraws.some((draw) => draw.string === "Paris"));
526+ }
527+});
modified packages/watch/test/protocol.test.js +152 -22
@@ -2,6 +2,7 @@ import test from "node:test";
22 import assert from "node:assert/strict";
33 import {
44 KEY_STATUS,
5+ LIMITS,
56 MESSAGE_TYPE,
67 REQUEST_TRIGGER,
78 SCHEMA_VERSION
@@ -21,11 +22,16 @@ function message(type, entries = []) {
2122 ]);
2223 }
2324
24-function configBegin(id = "config-1", count = 1, language = "fr") {
25+function configBegin(
26+ id = "config-1",
27+ count = 1,
28+ language = "fr",
29+ keyStatus = KEY_STATUS.CONFIGURED
30+) {
2531 return message(MESSAGE_TYPE.CONFIG_BEGIN, [
2632 ["REQUEST_ID", id],
2733 ["ITEM_COUNT", count],
28- ["KEY_STATUS", KEY_STATUS.CONFIGURED],
34+ ["KEY_STATUS", keyStatus],
2935 ["DISPLAY_NAME", language]
3036 ]);
3137 }
@@ -48,7 +54,7 @@ function configCommit(id = "config-1") {
4854 return message(MESSAGE_TYPE.CONFIG_COMMIT, [["REQUEST_ID", id]]);
4955 }
5056
51-function resultBegin(requestId = "request-1", favoriteId = "home", count = 1) {
57+function resultBegin(requestId = "consult-test-1", favoriteId = "home", count = 1) {
5258 return message(MESSAGE_TYPE.RESULT_BEGIN, [
5359 ["REQUEST_ID", requestId],
5460 ["FAVORITE_ID", favoriteId],
@@ -59,7 +65,7 @@ function resultBegin(requestId = "request-1", favoriteId = "home", count = 1) {
5965 ]);
6066 }
6167
62-function departure(requestId = "request-1", favoriteId = "home", index = 0) {
68+function departure(requestId = "consult-test-1", favoriteId = "home", index = 0) {
6369 return message(MESSAGE_TYPE.DEPARTURE, [
6470 ["REQUEST_ID", requestId],
6571 ["FAVORITE_ID", favoriteId],
@@ -72,14 +78,14 @@ function departure(requestId = "request-1", favoriteId = "home", index = 0) {
7278 ]);
7379 }
7480
75-function resultCommit(requestId = "request-1", favoriteId = "home") {
81+function resultCommit(requestId = "consult-test-1", favoriteId = "home") {
7682 return message(MESSAGE_TYPE.RESULT_COMMIT, [
7783 ["REQUEST_ID", requestId],
7884 ["FAVORITE_ID", favoriteId]
7985 ]);
8086 }
8187
82-function errorMessage(requestId = "request-1", favoriteId = "home") {
88+function errorMessage(requestId = "consult-test-1", favoriteId = "home") {
8389 return message(MESSAGE_TYPE.ERROR, [
8490 ["REQUEST_ID", requestId],
8591 ["FAVORITE_ID", favoriteId],
@@ -96,7 +102,7 @@ function commitConfiguration(receiver) {
96102 }
97103
98104 function commitResult(receiver) {
99- assert.equal(receiver.expectResponse("request-1", "home"), true);
105+ assert.equal(receiver.expectResponse("consult-test-1", "home", "consult-test-", 1), true);
100106 assert.equal(receiver.receive(resultBegin()), RECEIVE_RESULT.STAGED);
101107 assert.equal(receiver.receive(departure()), RECEIVE_RESULT.STAGED);
102108 assert.equal(receiver.receive(resultCommit()), RECEIVE_RESULT.RESULT_COMMITTED);
@@ -104,7 +110,7 @@ function commitResult(receiver) {
104110
105111 test("decoder validates all eight message types and request encoder returns aliases", () => {
106112 const request = encodeRequest({
107- requestId: "request-1",
113+ requestId: "consult-test-1",
108114 favoriteId: "home",
109115 trigger: REQUEST_TRIGGER.MANUAL_SELECT
110116 });
@@ -128,7 +134,7 @@ test("decoder validates all eight message types and request encoder returns alia
128134 ]);
129135 assert.equal([...request.keys()].some((key) => /^\d+$/u.test(key)), false);
130136 assert.throws(() => encodeRequest({
131- requestId: "request-1",
137+ requestId: "consult-test-1",
132138 favoriteId: "home",
133139 trigger: 99
134140 }), TypeError);
@@ -175,6 +181,18 @@ test("configuration language and favorites stay private until complete commit",
175181 });
176182 });
177183
184+test("idempotent restart synchronization reuses committed favorite objects", () => {
185+ const receiver = new ProtocolReceiver();
186+ commitConfiguration(receiver);
187+ const previous = receiver.borrowState().configuration.favorites[0];
188+
189+ assert.equal(receiver.receive(configBegin("restart")), RECEIVE_RESULT.STAGED);
190+ assert.equal(receiver.receive(favorite("restart")), RECEIVE_RESULT.STAGED);
191+ assert.strictEqual(receiver.configStage.favorites[0], previous);
192+ assert.equal(receiver.receive(configCommit("restart")), RECEIVE_RESULT.CONFIG_COMMITTED);
193+ assert.strictEqual(receiver.borrowState().configuration.favorites[0], previous);
194+});
195+
178196 test("out-of-order, duplicate, and incomplete configuration cannot replace committed state", () => {
179197 const receiver = new ProtocolReceiver();
180198 commitConfiguration(receiver);
@@ -195,14 +213,14 @@ test("out-of-order, duplicate, and incomplete configuration cannot replace commi
195213 test("result stages atomically only for the expected request and favorite", () => {
196214 const receiver = new ProtocolReceiver();
197215 assert.equal(receiver.receive(resultBegin()), RECEIVE_RESULT.REJECTED);
198- assert.equal(receiver.expectResponse("request-1", "home"), true);
216+ assert.equal(receiver.expectResponse("consult-test-1", "home", "consult-test-", 1), true);
199217 assert.equal(receiver.receive(resultBegin()), RECEIVE_RESULT.STAGED);
200218 assert.equal(receiver.snapshot().result, null);
201219 assert.equal(receiver.receive(departure()), RECEIVE_RESULT.STAGED);
202220 assert.equal(receiver.snapshot().result, null);
203221 assert.equal(receiver.receive(resultCommit()), RECEIVE_RESULT.RESULT_COMMITTED);
204222 assert.deepEqual(receiver.snapshot().result, {
205- requestId: "request-1",
223+ requestId: "consult-test-1",
206224 favoriteId: "home",
207225 fetchedAt: 1_788_000_000,
208226 sourceUpdatedAt: 1_787_999_990,
@@ -220,14 +238,14 @@ test("result stages atomically only for the expected request and favorite", () =
220238 test("matching request handshake permits one cached result replacement", () => {
221239 const receiver = new ProtocolReceiver();
222240 const request = encodeRequest({
223- requestId: "request-1",
241+ requestId: "consult-test-1",
224242 favoriteId: "home",
225243 trigger: REQUEST_TRIGGER.APP_OPEN
226244 });
227245 const freshBegin = resultBegin();
228246 freshBegin.set("FETCHED_AT", 1_788_000_060);
229247
230- assert.equal(receiver.expectResponse("request-1", "home"), true);
248+ assert.equal(receiver.expectResponse("consult-test-1", "home", "consult-test-", 1), true);
231249 assert.equal(receiver.receive(resultBegin()), RECEIVE_RESULT.STAGED);
232250 assert.equal(receiver.receive(departure()), RECEIVE_RESULT.STAGED);
233251 assert.equal(receiver.receive(resultCommit()), RECEIVE_RESULT.RESULT_COMMITTED);
@@ -241,20 +259,44 @@ test("matching request handshake permits one cached result replacement", () => {
241259 assert.equal(receiver.receive(request), RECEIVE_RESULT.REJECTED);
242260 });
243261
262+test("every generated request above the commit watermark remains acceptable", () => {
263+ const receiver = new ProtocolReceiver();
264+ assert.equal(receiver.expectResponse("consult-test-1", "home", "consult-test-", 1), true);
265+ assert.equal(receiver.expectResponse("consult-test-2", "home", "consult-test-", 2), true);
266+ assert.equal(receiver.expectResponse("consult-test-3", "home", "consult-test-", 3), true);
267+
268+ assert.equal(receiver.receive(resultBegin("consult-test-2")), RECEIVE_RESULT.STAGED);
269+ assert.equal(receiver.receive(departure("consult-test-2")), RECEIVE_RESULT.STAGED);
270+ assert.equal(
271+ receiver.receive(resultCommit("consult-test-2")),
272+ RECEIVE_RESULT.RESULT_COMMITTED
273+ );
274+ assert.equal(receiver.snapshot().result.requestId, "consult-test-2");
275+
276+ assert.equal(receiver.receive(resultBegin("consult-test-1")), RECEIVE_RESULT.REJECTED);
277+ assert.equal(receiver.receive(resultBegin("consult-test-3")), RECEIVE_RESULT.STAGED);
278+ assert.equal(receiver.receive(departure("consult-test-3")), RECEIVE_RESULT.STAGED);
279+ assert.equal(
280+ receiver.receive(resultCommit("consult-test-3")),
281+ RECEIVE_RESULT.RESULT_COMMITTED
282+ );
283+ assert.equal(receiver.snapshot().result.requestId, "consult-test-3");
284+});
285+
244286 test("late items, count mismatches, unsupported schema, and mismatched errors preserve state", () => {
245287 const receiver = new ProtocolReceiver();
246288 commitConfiguration(receiver);
247289 commitResult(receiver);
248290 const committed = receiver.snapshot();
249291
250- assert.equal(receiver.expectResponse("request-2", "home"), true);
251- assert.equal(receiver.receive(resultBegin("request-1", "home", 0)), RECEIVE_RESULT.REJECTED);
252- assert.equal(receiver.receive(resultBegin("request-2", "home", 2)), RECEIVE_RESULT.STAGED);
253- assert.equal(receiver.receive(departure("request-2", "home", 0)), RECEIVE_RESULT.STAGED);
254- assert.equal(receiver.receive(resultCommit("request-2", "home")), RECEIVE_RESULT.REJECTED);
292+ assert.equal(receiver.expectResponse("consult-test-2", "home", "consult-test-", 2), true);
293+ assert.equal(receiver.receive(resultBegin("consult-test-1", "home", 0)), RECEIVE_RESULT.REJECTED);
294+ assert.equal(receiver.receive(resultBegin("consult-test-2", "home", 2)), RECEIVE_RESULT.STAGED);
295+ assert.equal(receiver.receive(departure("consult-test-2", "home", 0)), RECEIVE_RESULT.STAGED);
296+ assert.equal(receiver.receive(resultCommit("consult-test-2", "home")), RECEIVE_RESULT.REJECTED);
255297 assert.deepEqual(receiver.snapshot(), committed);
256298
257- assert.equal(receiver.receive(errorMessage("request-1", "home")), RECEIVE_RESULT.REJECTED);
299+ assert.equal(receiver.receive(errorMessage("consult-test-1", "home")), RECEIVE_RESULT.REJECTED);
258300 const unsupported = configBegin("config-new", 0, "en");
259301 unsupported.set("SCHEMA_VERSION", 99);
260302 assert.equal(receiver.receive(unsupported), RECEIVE_RESULT.REJECTED);
@@ -265,11 +307,11 @@ test("matching error commits without destroying the prior complete result", () =
265307 const receiver = new ProtocolReceiver();
266308 commitResult(receiver);
267309 const result = receiver.snapshot().result;
268- assert.equal(receiver.expectResponse("request-2", "home"), true);
269- assert.equal(receiver.receive(errorMessage("request-2", "home")), RECEIVE_RESULT.ERROR_COMMITTED);
310+ assert.equal(receiver.expectResponse("consult-test-2", "home", "consult-test-", 2), true);
311+ assert.equal(receiver.receive(errorMessage("consult-test-2", "home")), RECEIVE_RESULT.ERROR_COMMITTED);
270312 assert.deepEqual(receiver.snapshot().result, result);
271313 assert.deepEqual(receiver.snapshot().error, {
272- requestId: "request-2",
314+ requestId: "consult-test-2",
273315 favoriteId: "home",
274316 code: "SOURCE_UNAVAILABLE",
275317 occurredAt: 1_788_000_001,
@@ -277,6 +319,32 @@ test("matching error commits without destroying the prior complete result", () =
277319 });
278320 });
279321
322+test("state restoration preserves a complete result and error but cancels transport state", () => {
323+ const receiver = new ProtocolReceiver();
324+ commitConfiguration(receiver);
325+ commitResult(receiver);
326+ assert.equal(receiver.expectResponse("consult-test-2", "home", "consult-test-", 2), true);
327+ assert.equal(
328+ receiver.receive(errorMessage("consult-test-2", "home")),
329+ RECEIVE_RESULT.ERROR_COMMITTED
330+ );
331+ const committed = receiver.snapshot();
332+
333+ assert.equal(receiver.expectResponse("consult-test-3", "home", "consult-test-", 3), true);
334+ assert.equal(
335+ receiver.receive(resultBegin("consult-test-3", "home")),
336+ RECEIVE_RESULT.STAGED
337+ );
338+ receiver.restoreState(committed);
339+
340+ assert.deepEqual(receiver.snapshot(), committed);
341+ assert.equal(
342+ receiver.receive(resultCommit("consult-test-3", "home")),
343+ RECEIVE_RESULT.REJECTED
344+ );
345+ assert.deepEqual(receiver.snapshot(), committed);
346+});
347+
280348 test("receiver seeds, restores, and idempotently repeats complete configuration", () => {
281349 const initial = {
282350 keyStatus: KEY_STATUS.CONFIGURED,
@@ -305,3 +373,65 @@ test("receiver seeds, restores, and idempotently repeats complete configuration"
305373 assert.equal(receiver.receive(configCommit("interrupted")), RECEIVE_RESULT.REJECTED);
306374 assert.deepEqual(receiver.snapshot(), committed);
307375 });
376+
377+test("configuration commits preserve only compatible results and cancel transport state", () => {
378+ const receiver = new ProtocolReceiver();
379+ commitConfiguration(receiver);
380+ commitResult(receiver);
381+ const result = receiver.snapshot().result;
382+
383+ assert.equal(receiver.expectResponse("consult-test-2", "home", "consult-test-", 2), true);
384+ assert.equal(
385+ receiver.receive(errorMessage("consult-test-2", "home")),
386+ RECEIVE_RESULT.ERROR_COMMITTED
387+ );
388+ assert.notEqual(receiver.snapshot().error, null);
389+
390+ assert.equal(receiver.expectResponse("consult-test-3", "home", "consult-test-", 3), true);
391+ assert.equal(
392+ receiver.receive(resultBegin("consult-test-3", "home", 1)),
393+ RECEIVE_RESULT.STAGED
394+ );
395+ assert.equal(
396+ receiver.receive(departure("consult-test-3", "home", 0)),
397+ RECEIVE_RESULT.STAGED
398+ );
399+ assert.equal(
400+ receiver.receive(configBegin("config-2", 1, "en", KEY_STATUS.INVALID)),
401+ RECEIVE_RESULT.STAGED
402+ );
403+ assert.equal(
404+ receiver.receive(favorite("config-2", 0, "home")),
405+ RECEIVE_RESULT.STAGED
406+ );
407+ assert.equal(
408+ receiver.receive(configCommit("config-2")),
409+ RECEIVE_RESULT.CONFIG_COMMITTED
410+ );
411+
412+ const compatible = receiver.snapshot();
413+ assert.equal(compatible.configuration.keyStatus, KEY_STATUS.INVALID);
414+ assert.equal(compatible.configuration.language, "en");
415+ assert.deepEqual(compatible.result, result);
416+ assert.equal(compatible.error, null);
417+ assert.equal(
418+ receiver.receive(resultCommit("consult-test-3", "home")),
419+ RECEIVE_RESULT.REJECTED
420+ );
421+ assert.deepEqual(receiver.snapshot(), compatible);
422+
423+ assert.equal(
424+ receiver.receive(configBegin("config-3", 1, "fr")),
425+ RECEIVE_RESULT.STAGED
426+ );
427+ assert.equal(
428+ receiver.receive(favorite("config-3", 0, "work")),
429+ RECEIVE_RESULT.STAGED
430+ );
431+ assert.equal(
432+ receiver.receive(configCommit("config-3")),
433+ RECEIVE_RESULT.CONFIG_COMMITTED
434+ );
435+ assert.equal(receiver.snapshot().result, null);
436+ assert.equal(receiver.snapshot().error, null);
437+});
@@ -2,6 +2,7 @@ import test from "node:test";
2 import assert from "node:assert/strict";2 import assert from "node:assert/strict";
3 import {3 import {
4 KEY_STATUS,4 KEY_STATUS,
5+ LIMITS,
5 MESSAGE_TYPE,6 MESSAGE_TYPE,
6 REQUEST_TRIGGER,7 REQUEST_TRIGGER,
7 SCHEMA_VERSION8 SCHEMA_VERSION
@@ -21,11 +22,16 @@ function message(type, entries = []) {
21 ]);22 ]);
22 }23 }
23 24
24-function configBegin(id = "config-1", count = 1, language = "fr") {25+function configBegin(
26+ id = "config-1",
27+ count = 1,
28+ language = "fr",
29+ keyStatus = KEY_STATUS.CONFIGURED
30+) {
25 return message(MESSAGE_TYPE.CONFIG_BEGIN, [31 return message(MESSAGE_TYPE.CONFIG_BEGIN, [
26 ["REQUEST_ID", id],32 ["REQUEST_ID", id],
27 ["ITEM_COUNT", count],33 ["ITEM_COUNT", count],
28- ["KEY_STATUS", KEY_STATUS.CONFIGURED],34+ ["KEY_STATUS", keyStatus],
29 ["DISPLAY_NAME", language]35 ["DISPLAY_NAME", language]
30 ]);36 ]);
31 }37 }
@@ -48,7 +54,7 @@ function configCommit(id = "config-1") {
48 return message(MESSAGE_TYPE.CONFIG_COMMIT, [["REQUEST_ID", id]]);54 return message(MESSAGE_TYPE.CONFIG_COMMIT, [["REQUEST_ID", id]]);
49 }55 }
50 56
51-function resultBegin(requestId = "request-1", favoriteId = "home", count = 1) {57+function resultBegin(requestId = "consult-test-1", favoriteId = "home", count = 1) {
52 return message(MESSAGE_TYPE.RESULT_BEGIN, [58 return message(MESSAGE_TYPE.RESULT_BEGIN, [
53 ["REQUEST_ID", requestId],59 ["REQUEST_ID", requestId],
54 ["FAVORITE_ID", favoriteId],60 ["FAVORITE_ID", favoriteId],
@@ -59,7 +65,7 @@ function resultBegin(requestId = "request-1", favoriteId = "home", count = 1) {
59 ]);65 ]);
60 }66 }
61 67
62-function departure(requestId = "request-1", favoriteId = "home", index = 0) {68+function departure(requestId = "consult-test-1", favoriteId = "home", index = 0) {
63 return message(MESSAGE_TYPE.DEPARTURE, [69 return message(MESSAGE_TYPE.DEPARTURE, [
64 ["REQUEST_ID", requestId],70 ["REQUEST_ID", requestId],
65 ["FAVORITE_ID", favoriteId],71 ["FAVORITE_ID", favoriteId],
@@ -72,14 +78,14 @@ function departure(requestId = "request-1", favoriteId = "home", index = 0) {
72 ]);78 ]);
73 }79 }
74 80
75-function resultCommit(requestId = "request-1", favoriteId = "home") {81+function resultCommit(requestId = "consult-test-1", favoriteId = "home") {
76 return message(MESSAGE_TYPE.RESULT_COMMIT, [82 return message(MESSAGE_TYPE.RESULT_COMMIT, [
77 ["REQUEST_ID", requestId],83 ["REQUEST_ID", requestId],
78 ["FAVORITE_ID", favoriteId]84 ["FAVORITE_ID", favoriteId]
79 ]);85 ]);
80 }86 }
81 87
82-function errorMessage(requestId = "request-1", favoriteId = "home") {88+function errorMessage(requestId = "consult-test-1", favoriteId = "home") {
83 return message(MESSAGE_TYPE.ERROR, [89 return message(MESSAGE_TYPE.ERROR, [
84 ["REQUEST_ID", requestId],90 ["REQUEST_ID", requestId],
85 ["FAVORITE_ID", favoriteId],91 ["FAVORITE_ID", favoriteId],
@@ -96,7 +102,7 @@ function commitConfiguration(receiver) {
96 }102 }
97 103
98 function commitResult(receiver) {104 function commitResult(receiver) {
99- assert.equal(receiver.expectResponse("request-1", "home"), true);105+ assert.equal(receiver.expectResponse("consult-test-1", "home", "consult-test-", 1), true);
100 assert.equal(receiver.receive(resultBegin()), RECEIVE_RESULT.STAGED);106 assert.equal(receiver.receive(resultBegin()), RECEIVE_RESULT.STAGED);
101 assert.equal(receiver.receive(departure()), RECEIVE_RESULT.STAGED);107 assert.equal(receiver.receive(departure()), RECEIVE_RESULT.STAGED);
102 assert.equal(receiver.receive(resultCommit()), RECEIVE_RESULT.RESULT_COMMITTED);108 assert.equal(receiver.receive(resultCommit()), RECEIVE_RESULT.RESULT_COMMITTED);
@@ -104,7 +110,7 @@ function commitResult(receiver) {
104 110
105 test("decoder validates all eight message types and request encoder returns aliases", () => {111 test("decoder validates all eight message types and request encoder returns aliases", () => {
106 const request = encodeRequest({112 const request = encodeRequest({
107- requestId: "request-1",113+ requestId: "consult-test-1",
108 favoriteId: "home",114 favoriteId: "home",
109 trigger: REQUEST_TRIGGER.MANUAL_SELECT115 trigger: REQUEST_TRIGGER.MANUAL_SELECT
110 });116 });
@@ -128,7 +134,7 @@ test("decoder validates all eight message types and request encoder returns alia
128 ]);134 ]);
129 assert.equal([...request.keys()].some((key) => /^\d+$/u.test(key)), false);135 assert.equal([...request.keys()].some((key) => /^\d+$/u.test(key)), false);
130 assert.throws(() => encodeRequest({136 assert.throws(() => encodeRequest({
131- requestId: "request-1",137+ requestId: "consult-test-1",
132 favoriteId: "home",138 favoriteId: "home",
133 trigger: 99139 trigger: 99
134 }), TypeError);140 }), TypeError);
@@ -175,6 +181,18 @@ test("configuration language and favorites stay private until complete commit",
175 });181 });
176 });182 });
177 183
184+test("idempotent restart synchronization reuses committed favorite objects", () => {
185+ const receiver = new ProtocolReceiver();
186+ commitConfiguration(receiver);
187+ const previous = receiver.borrowState().configuration.favorites[0];
188+
189+ assert.equal(receiver.receive(configBegin("restart")), RECEIVE_RESULT.STAGED);
190+ assert.equal(receiver.receive(favorite("restart")), RECEIVE_RESULT.STAGED);
191+ assert.strictEqual(receiver.configStage.favorites[0], previous);
192+ assert.equal(receiver.receive(configCommit("restart")), RECEIVE_RESULT.CONFIG_COMMITTED);
193+ assert.strictEqual(receiver.borrowState().configuration.favorites[0], previous);
194+});
195+
178 test("out-of-order, duplicate, and incomplete configuration cannot replace committed state", () => {196 test("out-of-order, duplicate, and incomplete configuration cannot replace committed state", () => {
179 const receiver = new ProtocolReceiver();197 const receiver = new ProtocolReceiver();
180 commitConfiguration(receiver);198 commitConfiguration(receiver);
@@ -195,14 +213,14 @@ test("out-of-order, duplicate, and incomplete configuration cannot replace commi
195 test("result stages atomically only for the expected request and favorite", () => {213 test("result stages atomically only for the expected request and favorite", () => {
196 const receiver = new ProtocolReceiver();214 const receiver = new ProtocolReceiver();
197 assert.equal(receiver.receive(resultBegin()), RECEIVE_RESULT.REJECTED);215 assert.equal(receiver.receive(resultBegin()), RECEIVE_RESULT.REJECTED);
198- assert.equal(receiver.expectResponse("request-1", "home"), true);216+ assert.equal(receiver.expectResponse("consult-test-1", "home", "consult-test-", 1), true);
199 assert.equal(receiver.receive(resultBegin()), RECEIVE_RESULT.STAGED);217 assert.equal(receiver.receive(resultBegin()), RECEIVE_RESULT.STAGED);
200 assert.equal(receiver.snapshot().result, null);218 assert.equal(receiver.snapshot().result, null);
201 assert.equal(receiver.receive(departure()), RECEIVE_RESULT.STAGED);219 assert.equal(receiver.receive(departure()), RECEIVE_RESULT.STAGED);
202 assert.equal(receiver.snapshot().result, null);220 assert.equal(receiver.snapshot().result, null);
203 assert.equal(receiver.receive(resultCommit()), RECEIVE_RESULT.RESULT_COMMITTED);221 assert.equal(receiver.receive(resultCommit()), RECEIVE_RESULT.RESULT_COMMITTED);
204 assert.deepEqual(receiver.snapshot().result, {222 assert.deepEqual(receiver.snapshot().result, {
205- requestId: "request-1",223+ requestId: "consult-test-1",
206 favoriteId: "home",224 favoriteId: "home",
207 fetchedAt: 1_788_000_000,225 fetchedAt: 1_788_000_000,
208 sourceUpdatedAt: 1_787_999_990,226 sourceUpdatedAt: 1_787_999_990,
@@ -220,14 +238,14 @@ test("result stages atomically only for the expected request and favorite", () =
220 test("matching request handshake permits one cached result replacement", () => {238 test("matching request handshake permits one cached result replacement", () => {
221 const receiver = new ProtocolReceiver();239 const receiver = new ProtocolReceiver();
222 const request = encodeRequest({240 const request = encodeRequest({
223- requestId: "request-1",241+ requestId: "consult-test-1",
224 favoriteId: "home",242 favoriteId: "home",
225 trigger: REQUEST_TRIGGER.APP_OPEN243 trigger: REQUEST_TRIGGER.APP_OPEN
226 });244 });
227 const freshBegin = resultBegin();245 const freshBegin = resultBegin();
228 freshBegin.set("FETCHED_AT", 1_788_000_060);246 freshBegin.set("FETCHED_AT", 1_788_000_060);
229 247
230- assert.equal(receiver.expectResponse("request-1", "home"), true);248+ assert.equal(receiver.expectResponse("consult-test-1", "home", "consult-test-", 1), true);
231 assert.equal(receiver.receive(resultBegin()), RECEIVE_RESULT.STAGED);249 assert.equal(receiver.receive(resultBegin()), RECEIVE_RESULT.STAGED);
232 assert.equal(receiver.receive(departure()), RECEIVE_RESULT.STAGED);250 assert.equal(receiver.receive(departure()), RECEIVE_RESULT.STAGED);
233 assert.equal(receiver.receive(resultCommit()), RECEIVE_RESULT.RESULT_COMMITTED);251 assert.equal(receiver.receive(resultCommit()), RECEIVE_RESULT.RESULT_COMMITTED);
@@ -241,20 +259,44 @@ test("matching request handshake permits one cached result replacement", () => {
241 assert.equal(receiver.receive(request), RECEIVE_RESULT.REJECTED);259 assert.equal(receiver.receive(request), RECEIVE_RESULT.REJECTED);
242 });260 });
243 261
262+test("every generated request above the commit watermark remains acceptable", () => {
263+ const receiver = new ProtocolReceiver();
264+ assert.equal(receiver.expectResponse("consult-test-1", "home", "consult-test-", 1), true);
265+ assert.equal(receiver.expectResponse("consult-test-2", "home", "consult-test-", 2), true);
266+ assert.equal(receiver.expectResponse("consult-test-3", "home", "consult-test-", 3), true);
267+
268+ assert.equal(receiver.receive(resultBegin("consult-test-2")), RECEIVE_RESULT.STAGED);
269+ assert.equal(receiver.receive(departure("consult-test-2")), RECEIVE_RESULT.STAGED);
270+ assert.equal(
271+ receiver.receive(resultCommit("consult-test-2")),
272+ RECEIVE_RESULT.RESULT_COMMITTED
273+ );
274+ assert.equal(receiver.snapshot().result.requestId, "consult-test-2");
275+
276+ assert.equal(receiver.receive(resultBegin("consult-test-1")), RECEIVE_RESULT.REJECTED);
277+ assert.equal(receiver.receive(resultBegin("consult-test-3")), RECEIVE_RESULT.STAGED);
278+ assert.equal(receiver.receive(departure("consult-test-3")), RECEIVE_RESULT.STAGED);
279+ assert.equal(
280+ receiver.receive(resultCommit("consult-test-3")),
281+ RECEIVE_RESULT.RESULT_COMMITTED
282+ );
283+ assert.equal(receiver.snapshot().result.requestId, "consult-test-3");
284+});
285+
244 test("late items, count mismatches, unsupported schema, and mismatched errors preserve state", () => {286 test("late items, count mismatches, unsupported schema, and mismatched errors preserve state", () => {
245 const receiver = new ProtocolReceiver();287 const receiver = new ProtocolReceiver();
246 commitConfiguration(receiver);288 commitConfiguration(receiver);
247 commitResult(receiver);289 commitResult(receiver);
248 const committed = receiver.snapshot();290 const committed = receiver.snapshot();
249 291
250- assert.equal(receiver.expectResponse("request-2", "home"), true);292+ assert.equal(receiver.expectResponse("consult-test-2", "home", "consult-test-", 2), true);
251- assert.equal(receiver.receive(resultBegin("request-1", "home", 0)), RECEIVE_RESULT.REJECTED);293+ assert.equal(receiver.receive(resultBegin("consult-test-1", "home", 0)), RECEIVE_RESULT.REJECTED);
252- assert.equal(receiver.receive(resultBegin("request-2", "home", 2)), RECEIVE_RESULT.STAGED);294+ assert.equal(receiver.receive(resultBegin("consult-test-2", "home", 2)), RECEIVE_RESULT.STAGED);
253- assert.equal(receiver.receive(departure("request-2", "home", 0)), RECEIVE_RESULT.STAGED);295+ assert.equal(receiver.receive(departure("consult-test-2", "home", 0)), RECEIVE_RESULT.STAGED);
254- assert.equal(receiver.receive(resultCommit("request-2", "home")), RECEIVE_RESULT.REJECTED);296+ assert.equal(receiver.receive(resultCommit("consult-test-2", "home")), RECEIVE_RESULT.REJECTED);
255 assert.deepEqual(receiver.snapshot(), committed);297 assert.deepEqual(receiver.snapshot(), committed);
256 298
257- assert.equal(receiver.receive(errorMessage("request-1", "home")), RECEIVE_RESULT.REJECTED);299+ assert.equal(receiver.receive(errorMessage("consult-test-1", "home")), RECEIVE_RESULT.REJECTED);
258 const unsupported = configBegin("config-new", 0, "en");300 const unsupported = configBegin("config-new", 0, "en");
259 unsupported.set("SCHEMA_VERSION", 99);301 unsupported.set("SCHEMA_VERSION", 99);
260 assert.equal(receiver.receive(unsupported), RECEIVE_RESULT.REJECTED);302 assert.equal(receiver.receive(unsupported), RECEIVE_RESULT.REJECTED);
@@ -265,11 +307,11 @@ test("matching error commits without destroying the prior complete result", () =
265 const receiver = new ProtocolReceiver();307 const receiver = new ProtocolReceiver();
266 commitResult(receiver);308 commitResult(receiver);
267 const result = receiver.snapshot().result;309 const result = receiver.snapshot().result;
268- assert.equal(receiver.expectResponse("request-2", "home"), true);310+ assert.equal(receiver.expectResponse("consult-test-2", "home", "consult-test-", 2), true);
269- assert.equal(receiver.receive(errorMessage("request-2", "home")), RECEIVE_RESULT.ERROR_COMMITTED);311+ assert.equal(receiver.receive(errorMessage("consult-test-2", "home")), RECEIVE_RESULT.ERROR_COMMITTED);
270 assert.deepEqual(receiver.snapshot().result, result);312 assert.deepEqual(receiver.snapshot().result, result);
271 assert.deepEqual(receiver.snapshot().error, {313 assert.deepEqual(receiver.snapshot().error, {
272- requestId: "request-2",314+ requestId: "consult-test-2",
273 favoriteId: "home",315 favoriteId: "home",
274 code: "SOURCE_UNAVAILABLE",316 code: "SOURCE_UNAVAILABLE",
275 occurredAt: 1_788_000_001,317 occurredAt: 1_788_000_001,
@@ -277,6 +319,32 @@ test("matching error commits without destroying the prior complete result", () =
277 });319 });
278 });320 });
279 321
322+test("state restoration preserves a complete result and error but cancels transport state", () => {
323+ const receiver = new ProtocolReceiver();
324+ commitConfiguration(receiver);
325+ commitResult(receiver);
326+ assert.equal(receiver.expectResponse("consult-test-2", "home", "consult-test-", 2), true);
327+ assert.equal(
328+ receiver.receive(errorMessage("consult-test-2", "home")),
329+ RECEIVE_RESULT.ERROR_COMMITTED
330+ );
331+ const committed = receiver.snapshot();
332+
333+ assert.equal(receiver.expectResponse("consult-test-3", "home", "consult-test-", 3), true);
334+ assert.equal(
335+ receiver.receive(resultBegin("consult-test-3", "home")),
336+ RECEIVE_RESULT.STAGED
337+ );
338+ receiver.restoreState(committed);
339+
340+ assert.deepEqual(receiver.snapshot(), committed);
341+ assert.equal(
342+ receiver.receive(resultCommit("consult-test-3", "home")),
343+ RECEIVE_RESULT.REJECTED
344+ );
345+ assert.deepEqual(receiver.snapshot(), committed);
346+});
347+
280 test("receiver seeds, restores, and idempotently repeats complete configuration", () => {348 test("receiver seeds, restores, and idempotently repeats complete configuration", () => {
281 const initial = {349 const initial = {
282 keyStatus: KEY_STATUS.CONFIGURED,350 keyStatus: KEY_STATUS.CONFIGURED,
@@ -305,3 +373,65 @@ test("receiver seeds, restores, and idempotently repeats complete configuration"
305 assert.equal(receiver.receive(configCommit("interrupted")), RECEIVE_RESULT.REJECTED);373 assert.equal(receiver.receive(configCommit("interrupted")), RECEIVE_RESULT.REJECTED);
306 assert.deepEqual(receiver.snapshot(), committed);374 assert.deepEqual(receiver.snapshot(), committed);
307 });375 });
376+
377+test("configuration commits preserve only compatible results and cancel transport state", () => {
378+ const receiver = new ProtocolReceiver();
379+ commitConfiguration(receiver);
380+ commitResult(receiver);
381+ const result = receiver.snapshot().result;
382+
383+ assert.equal(receiver.expectResponse("consult-test-2", "home", "consult-test-", 2), true);
384+ assert.equal(
385+ receiver.receive(errorMessage("consult-test-2", "home")),
386+ RECEIVE_RESULT.ERROR_COMMITTED
387+ );
388+ assert.notEqual(receiver.snapshot().error, null);
389+
390+ assert.equal(receiver.expectResponse("consult-test-3", "home", "consult-test-", 3), true);
391+ assert.equal(
392+ receiver.receive(resultBegin("consult-test-3", "home", 1)),
393+ RECEIVE_RESULT.STAGED
394+ );
395+ assert.equal(
396+ receiver.receive(departure("consult-test-3", "home", 0)),
397+ RECEIVE_RESULT.STAGED
398+ );
399+ assert.equal(
400+ receiver.receive(configBegin("config-2", 1, "en", KEY_STATUS.INVALID)),
401+ RECEIVE_RESULT.STAGED
402+ );
403+ assert.equal(
404+ receiver.receive(favorite("config-2", 0, "home")),
405+ RECEIVE_RESULT.STAGED
406+ );
407+ assert.equal(
408+ receiver.receive(configCommit("config-2")),
409+ RECEIVE_RESULT.CONFIG_COMMITTED
410+ );
411+
412+ const compatible = receiver.snapshot();
413+ assert.equal(compatible.configuration.keyStatus, KEY_STATUS.INVALID);
414+ assert.equal(compatible.configuration.language, "en");
415+ assert.deepEqual(compatible.result, result);
416+ assert.equal(compatible.error, null);
417+ assert.equal(
418+ receiver.receive(resultCommit("consult-test-3", "home")),
419+ RECEIVE_RESULT.REJECTED
420+ );
421+ assert.deepEqual(receiver.snapshot(), compatible);
422+
423+ assert.equal(
424+ receiver.receive(configBegin("config-3", 1, "fr")),
425+ RECEIVE_RESULT.STAGED
426+ );
427+ assert.equal(
428+ receiver.receive(favorite("config-3", 0, "work")),
429+ RECEIVE_RESULT.STAGED
430+ );
431+ assert.equal(
432+ receiver.receive(configCommit("config-3")),
433+ RECEIVE_RESULT.CONFIG_COMMITTED
434+ );
435+ assert.equal(receiver.snapshot().result, null);
436+ assert.equal(receiver.snapshot().error, null);
437+});
modified scripts/build.mjs +4 -5
@@ -1,5 +1,5 @@
11 import { spawnSync } from "node:child_process";
2-import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2+import { cpSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
33 import { basename, join } from "node:path";
44 import { fileURLToPath } from "node:url";
55
@@ -32,8 +32,8 @@ export function readBuildConfiguration(environment) {
3232 };
3333 }
3434
35-export function createBootstrap(fixture, configuration) {
36- return `"use strict";\n\nvar createCompanion = require("./companion").createCompanion;\nvar fixture = ${JSON.stringify(fixture)};\n\ncreateCompanion({\n Pebble: Pebble,\n storage: localStorage,\n XHR: XMLHttpRequest,\n clock: { now: Date.now },\n defer: function (callback) { setTimeout(callback, 0); },\n backendUrl: ${JSON.stringify(configuration.backendUrl)},\n configurationUrl: ${JSON.stringify(configuration.configurationUrl)},\n fixture: fixture\n});\n`;
35+export function createBootstrap(configuration) {
36+ return `"use strict";\n\nvar createCompanion = require("./companion").createCompanion;\n\ncreateCompanion({\n Pebble: Pebble,\n storage: localStorage,\n XHR: XMLHttpRequest,\n clock: { now: Date.now },\n defer: function (callback) { setTimeout(callback, 0); },\n readyDefer: function (callback) { setTimeout(callback, 250); },\n backendUrl: ${JSON.stringify(configuration.backendUrl)},\n configurationUrl: ${JSON.stringify(configuration.configurationUrl)}\n});\n`;
3737 }
3838
3939 function run(command, args, cwd = root) {
@@ -48,7 +48,6 @@ function run(command, args, cwd = root) {
4848
4949 function build() {
5050 const configuration = readBuildConfiguration(process.env);
51- const fixture = JSON.parse(readFileSync(join(root, "fixtures/departures/foundation.json"), "utf8"));
5251
5352 run(process.execPath, ["scripts/test.mjs"]);
5453 run(process.execPath, ["scripts/measure-radio.mjs"]);
@@ -59,7 +58,7 @@ function build() {
5958 cpSync(join(companionSource, source), join(pkjsTarget, basename(source)));
6059 }
6160 cpSync(join(companionSource, "index.js"), join(pkjsTarget, "companion.js"));
62- writeFileSync(join(pkjsTarget, "index.js"), createBootstrap(fixture, configuration));
61+ writeFileSync(join(pkjsTarget, "index.js"), createBootstrap(configuration));
6362
6463 const watch = join(root, "packages/watch");
6564 run("pebble", ["clean"], watch);
@@ -1,5 +1,5 @@
1 import { spawnSync } from "node:child_process";1 import { spawnSync } from "node:child_process";
2-import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";2+import { cpSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3 import { basename, join } from "node:path";3 import { basename, join } from "node:path";
4 import { fileURLToPath } from "node:url";4 import { fileURLToPath } from "node:url";
5 5
@@ -32,8 +32,8 @@ export function readBuildConfiguration(environment) {
32 };32 };
33 }33 }
34 34
35-export function createBootstrap(fixture, configuration) {35+export function createBootstrap(configuration) {
36- return `"use strict";\n\nvar createCompanion = require("./companion").createCompanion;\nvar fixture = ${JSON.stringify(fixture)};\n\ncreateCompanion({\n Pebble: Pebble,\n storage: localStorage,\n XHR: XMLHttpRequest,\n clock: { now: Date.now },\n defer: function (callback) { setTimeout(callback, 0); },\n backendUrl: ${JSON.stringify(configuration.backendUrl)},\n configurationUrl: ${JSON.stringify(configuration.configurationUrl)},\n fixture: fixture\n});\n`;36+ return `"use strict";\n\nvar createCompanion = require("./companion").createCompanion;\n\ncreateCompanion({\n Pebble: Pebble,\n storage: localStorage,\n XHR: XMLHttpRequest,\n clock: { now: Date.now },\n defer: function (callback) { setTimeout(callback, 0); },\n readyDefer: function (callback) { setTimeout(callback, 250); },\n backendUrl: ${JSON.stringify(configuration.backendUrl)},\n configurationUrl: ${JSON.stringify(configuration.configurationUrl)}\n});\n`;
37 }37 }
38 38
39 function run(command, args, cwd = root) {39 function run(command, args, cwd = root) {
@@ -48,7 +48,6 @@ function run(command, args, cwd = root) {
48 48
49 function build() {49 function build() {
50 const configuration = readBuildConfiguration(process.env);50 const configuration = readBuildConfiguration(process.env);
51- const fixture = JSON.parse(readFileSync(join(root, "fixtures/departures/foundation.json"), "utf8"));
52 51
53 run(process.execPath, ["scripts/test.mjs"]);52 run(process.execPath, ["scripts/test.mjs"]);
54 run(process.execPath, ["scripts/measure-radio.mjs"]);53 run(process.execPath, ["scripts/measure-radio.mjs"]);
@@ -59,7 +58,7 @@ function build() {
59 cpSync(join(companionSource, source), join(pkjsTarget, basename(source)));58 cpSync(join(companionSource, source), join(pkjsTarget, basename(source)));
60 }59 }
61 cpSync(join(companionSource, "index.js"), join(pkjsTarget, "companion.js"));60 cpSync(join(companionSource, "index.js"), join(pkjsTarget, "companion.js"));
62- writeFileSync(join(pkjsTarget, "index.js"), createBootstrap(fixture, configuration));61+ writeFileSync(join(pkjsTarget, "index.js"), createBootstrap(configuration));
63 62
64 const watch = join(root, "packages/watch");63 const watch = join(root, "packages/watch");
65 run("pebble", ["clean"], watch);64 run("pebble", ["clean"], watch);
modified scripts/measure-radio.mjs +108 -38
@@ -5,6 +5,10 @@ const require = createRequire(import.meta.url);
55 const codec = require("../packages/companion/src/codec.js");
66 const contracts = require("../packages/companion/src/contracts.js");
77 const configuration = require("../packages/companion/src/configuration.js");
8+import {
9+ APP_MESSAGE_INBOX_BYTES,
10+ APP_MESSAGE_OUTBOX_BYTES,
11+} from "../packages/watch/src/embeddedjs/contracts.js";
812 import {
913 serializeWatchConfiguration,
1014 WATCH_CONFIGURATION_MAX_BYTES,
@@ -24,6 +28,21 @@ function dictionarySize(dictionary) {
2428 return 1 + (7 * sizes.length) + sizes.reduce((total, size) => total + size, 0);
2529 }
2630
31+function dictionaryMaximumsByMessageType(messages) {
32+ const maximums = Object.fromEntries(
33+ Object.keys(contracts.MESSAGE_TYPE).map((name) => [name, null]),
34+ );
35+ for (const message of messages) {
36+ const name = Object.keys(contracts.MESSAGE_TYPE).find(
37+ (candidate) => contracts.MESSAGE_TYPE[candidate] === message.MESSAGE_TYPE,
38+ );
39+ if (name === undefined) throw new Error("codec emitted an unknown message type");
40+ const size = dictionarySize(message);
41+ maximums[name] = maximums[name] === null ? size : Math.max(maximums[name], size);
42+ }
43+ return maximums;
44+}
45+
2746 function maximumEncodedString(byteLimit, suffix = "") {
2847 return "\\".repeat(byteLimit - Buffer.byteLength(suffix, "utf8")) + suffix;
2948 }
@@ -33,22 +52,8 @@ function longestLabel(values) {
3352 return values.reduce((longest, value) => (value.length > longest.length ? value : longest));
3453 }
3554
36-// AppMessage capacity actually configured by the watch app. The watch
37-// bootstrap (packages/watch/src/embeddedjs/main.js) constructs
38-// `new Message({ keys: APP_MESSAGE_KEY_MAP, ... })` with no input/output
39-// bound, so the Moddable bridge
40-// (RePebble SDK 4.33.1 toolchain/moddable/build/devices/pebble/modules/
41-// message/pebble-appmessage.c) opens
42-// app_message_open(app_message_inbox_size_maximum(),
43-// app_message_outbox_size_maximum()). On the SDK 4.33.1 firmware those
44-// return 2026 (0x7ea) and 654 (0x28e) bytes for a JS-allowed app; both
45-// constants disassemble identically from the emery and gabbro qemu
46-// *_sdk_debug.elf images. A phone companion advertising the 8k message
47-// capability lifts both maxima to 8200 (0x2008), which this budget does not
48-// assume. AppMessage buffers live in the application heap (pebble.h), inside
49-// the 56 KiB XS arena budget enforced at compile time in mdbl.c.
50-const appMessageInboxBytes = 2026;
51-const appMessageOutboxBytes = 654;
55+// An explicit runtime bound prevents an 8k-capable phone connection from
56+// expanding both native Pebble buffers to 8200 bytes.
5257 const appMessageEightKilobyteBytes = 8200;
5358
5459 // Maximal frozen DepartureResult bounds from the contracts validators
@@ -68,10 +73,10 @@ const maximumDeparture = {
6873 nextIntervalMinutes: 1440,
6974 };
7075
71-function maximumResult(favoriteId) {
76+function maximumResult(requestId, favoriteId) {
7277 return {
7378 schemaVersion: contracts.SCHEMA_VERSION,
74- requestId: maximumEncodedString(contracts.LIMITS.idUtf8Bytes),
79+ requestId,
7580 favoriteId,
7681 fetchedAt: UINT32_MAX,
7782 sourceUpdatedAt: UINT32_MAX,
@@ -83,12 +88,8 @@ function maximumResult(favoriteId) {
8388 };
8489 }
8590
86-const request = codec.encodeRequest({
87- requestId: fixture.result.requestId,
88- favoriteId: fixture.favorite.id,
89- trigger: contracts.REQUEST_TRIGGER.APP_OPEN,
90-});
91-const response = codec.encodeResult(fixture.result);
91+const maximumRequestId = maximumEncodedString(contracts.LIMITS.idUtf8Bytes, "request");
92+const maximumSequenceId = maximumEncodedString(contracts.LIMITS.idUtf8Bytes, "sequence");
9293 const maximumApiKey = maximumEncodedString(contracts.LIMITS.apiKeyUtf8Bytes);
9394 const maximumFavorites = Array.from({ length: contracts.LIMITS.favorites }, (_, index) => {
9495 const suffix = String(index).padStart(2, "0");
@@ -103,12 +104,45 @@ const maximumFavorites = Array.from({ length: contracts.LIMITS.favorites }, (_,
103104 sortOrder: index,
104105 };
105106 });
107+const maximumFavoriteId = maximumFavorites[0].id;
108+const maximumRequestShape = {
109+ requestId: maximumRequestId,
110+ favoriteId: maximumFavoriteId,
111+ trigger: contracts.REQUEST_TRIGGER.MANUAL_SELECT,
112+};
113+const maximumDepartureResult = maximumResult(maximumRequestId, maximumFavoriteId);
114+const maximumErrorResult = {
115+ schemaVersion: contracts.SCHEMA_VERSION,
116+ requestId: maximumRequestId,
117+ favoriteId: maximumFavoriteId,
118+ code: longestLabel(contracts.ERROR_CODE),
119+ occurredAt: UINT32_MAX,
120+ retryAfterSeconds: UINT32_MAX,
121+};
122+
123+const request = codec.encodeRequest({
124+ requestId: fixture.result.requestId,
125+ favoriteId: fixture.favorite.id,
126+ trigger: contracts.REQUEST_TRIGGER.APP_OPEN,
127+});
128+const response = codec.encodeResult(fixture.result);
129+const maximumWatchRequest = codec.encodeRequest(maximumRequestShape);
130+const maximumMirroredRequest = codec.encodeRequest(maximumRequestShape);
106131 const configurationMessages = codec.encodeConfiguration(
107- "config-maximum",
132+ maximumSequenceId,
108133 maximumFavorites,
109134 contracts.KEY_STATUS.CONFIGURED,
110135 contracts.WIRE_LANGUAGE.FR,
111136 );
137+const maximumResultMessages = codec.encodeResult(maximumDepartureResult);
138+const maximumErrorMessage = codec.encodeError(maximumErrorResult);
139+const maximumPhoneToWatchMessages = [
140+ ...configurationMessages,
141+ ...maximumResultMessages,
142+ maximumMirroredRequest,
143+ maximumErrorMessage,
144+];
145+const maximumWatchToPhoneMessages = [maximumWatchRequest];
112146
113147 // Maximal lapinFuteResults record per the configuration validators
114148 // (packages/companion/src/configuration.js isStoredResults/isResultEntry):
@@ -118,7 +152,7 @@ const configurationMessages = codec.encodeConfiguration(
118152 const maximumResultEntries = maximumFavorites.map((favorite) => ({
119153 favoriteId: favorite.id,
120154 storedAt: Number.MAX_SAFE_INTEGER,
121- result: maximumResult(favorite.id),
155+ result: maximumResult(maximumRequestId, favorite.id),
122156 }));
123157 if (!maximumResultEntries.every((entry) => contracts.isDepartureResult(entry.result))) {
124158 throw new Error("maximum result must satisfy the frozen DepartureResult contract");
@@ -131,6 +165,23 @@ const bytes = sizes.reduce((total, size) => total + size, 0);
131165 const largestPhoneDictionaryBytes = Math.max(...sizes.slice(1));
132166 const largestConfigurationDictionaryBytes = Math.max(...configurationSizes);
133167 const configurationEncodedBytes = configurationSizes.reduce((total, size) => total + size, 0);
168+const maximumPhoneToWatchSizes = maximumPhoneToWatchMessages.map(dictionarySize);
169+const maximumWatchToPhoneSizes = maximumWatchToPhoneMessages.map(dictionarySize);
170+const maximumDictionaryBytesByMessageType = dictionaryMaximumsByMessageType([
171+ ...maximumWatchToPhoneMessages,
172+ ...maximumPhoneToWatchMessages,
173+]);
174+const allMaximumMessageTypesMeasured = Object.values(
175+ maximumDictionaryBytesByMessageType,
176+).every((size) => size !== null);
177+const maximumPhoneDictionariesFitInbox = maximumPhoneToWatchSizes.every(
178+ (size) => size <= APP_MESSAGE_INBOX_BYTES,
179+);
180+const maximumWatchRequestsFitOutbox = maximumWatchToPhoneSizes.every(
181+ (size) => size <= APP_MESSAGE_OUTBOX_BYTES,
182+);
183+const largestMaximumPhoneDictionaryBytes = Math.max(...maximumPhoneToWatchSizes);
184+const largestMaximumWatchDictionaryBytes = Math.max(...maximumWatchToPhoneSizes);
134185 const phoneRecordBytes = Buffer.byteLength(JSON.stringify({
135186 schemaVersion: contracts.SCHEMA_VERSION,
136187 favorites: maximumFavorites,
@@ -198,12 +249,12 @@ const capacities = {
198249 source: "README.md quality gates: two-departure refresh at most 2048 encoded bytes",
199250 },
200251 requestBytes: {
201- value: 192,
202- source: "README.md quality gates: watch request at most 192 encoded bytes; travels watch-to-phone through the 654-byte outbox",
252+ value: APP_MESSAGE_OUTBOX_BYTES,
253+ source: "README.md watch-request gate, sourced from APP_MESSAGE_OUTBOX_BYTES and passed to main.js Message output",
203254 },
204255 phoneDictionaryBytes: {
205- value: 768,
206- source: "README.md quality gates: phone-to-watch dictionary at most 768 encoded bytes; the frozen repository budget, tighter than the 2026-byte watch inbox actually configured by the watch app",
256+ value: APP_MESSAGE_INBOX_BYTES,
257+ source: "README.md phone-to-watch dictionary gate, sourced from APP_MESSAGE_INBOX_BYTES and passed to main.js Message input",
207258 },
208259 configurationMessages: {
209260 value: contracts.LIMITS.favorites + 2,
@@ -211,19 +262,19 @@ const capacities = {
211262 },
212263 configurationEncodedBytes: {
213264 value: 8192,
214- source: "frozen v1 configuration-batch budget carried from the foundation measurement plan in this script (power-of-two batch headroom); the binding per-dictionary gate is appMessageInboxBytes",
265+ source: "frozen v1 configuration-batch budget carried from the foundation measurement plan in this script (power-of-two batch headroom); the binding per-dictionary gate is APP_MESSAGE_INBOX_BYTES",
215266 },
216267 appMessageInboxBytes: {
217- value: appMessageInboxBytes,
218- source: "watch inbox capacity configured by the watch app: main.js passes no Message input bound, so Moddable pebble-appmessage.c opens app_message_open(app_message_inbox_size_maximum(), app_message_outbox_size_maximum()); RePebble SDK 4.33.1 emery/gabbro firmware returns 2026 bytes (0x7ea) for a JS-allowed app (disassembled from both qemu *_sdk_debug.elf images; 126 bytes for non-JS apps)",
268+ value: APP_MESSAGE_INBOX_BYTES,
269+ source: "packages/watch/src/embeddedjs/contracts.js APP_MESSAGE_INBOX_BYTES, passed to main.js Message input",
219270 },
220271 appMessageOutboxBytes: {
221- value: appMessageOutboxBytes,
222- source: "watch outbox capacity from app_message_outbox_size_maximum() in the same RePebble SDK 4.33.1 emery/gabbro firmware images: 654 bytes (0x28e) without the 8k phone capability",
272+ value: APP_MESSAGE_OUTBOX_BYTES,
273+ source: "packages/watch/src/embeddedjs/contracts.js APP_MESSAGE_OUTBOX_BYTES, passed to main.js Message output",
223274 },
224275 appMessageEightKilobyteBytes: {
225276 value: appMessageEightKilobyteBytes,
226- source: "phone companions advertising the 8k message capability (prv_supports_8k) lift both AppMessage maxima to 8200 bytes (0x2008); not assumed by this budget",
277+ source: "phone companions advertising the 8k message capability can expose 8200-byte maxima, but explicit Message input/output bounds prevent those native allocations",
227278 },
228279 watchUnsentQueueMessages: {
229280 value: 4,
@@ -231,7 +282,7 @@ const capacities = {
231282 },
232283 xsArenaBudgetBytes: {
233284 value: 56 * 1024,
234- source: "mdbl.c XS_STACK_BYTES + XS_SLOT_BYTES + XS_CHUNK_BYTES = 56 KiB enforced by a compile-time #error; README loaded image and RAM gate with XS arenas at least 25% free; AppMessage inbox/outbox buffers are allocated from this application heap (pebble.h)",
285+ source: "mdbl.c XS_STACK_BYTES + XS_SLOT_BYTES + XS_CHUNK_BYTES = 56 KiB enforced by a compile-time #error; native AppMessage inbox/outbox buffers are separate allocations from the same application heap, in addition to the XS arena total",
235286 },
236287 phoneRecordBytes: {
237288 value: 16384,
@@ -276,6 +327,22 @@ const report = {
276327 requestBytes: sizes[0],
277328 largestPhoneDictionaryBytes,
278329 },
330+ maximumDictionaries: {
331+ bytesByMessageType: maximumDictionaryBytesByMessageType,
332+ phoneToWatch: {
333+ messages: maximumPhoneToWatchMessages.length,
334+ includesMirroredRequest: true,
335+ inboxBytes: APP_MESSAGE_INBOX_BYTES,
336+ largestDictionaryBytes: largestMaximumPhoneDictionaryBytes,
337+ allFit: maximumPhoneDictionariesFitInbox,
338+ },
339+ watchToPhone: {
340+ messages: maximumWatchToPhoneMessages.length,
341+ outboxBytes: APP_MESSAGE_OUTBOX_BYTES,
342+ largestDictionaryBytes: largestMaximumWatchDictionaryBytes,
343+ allFit: maximumWatchRequestsFitOutbox,
344+ },
345+ },
279346 maximumConfiguration: {
280347 messages: configurationMessages.length,
281348 encodedBytes: configurationEncodedBytes,
@@ -296,10 +363,13 @@ const report = {
296363 invalidKeyStatusRecordBytes,
297364 totalBytes: phoneStorageBytes,
298365 },
299- scenario: "steady-state request/result plus a complete maximum-favorite atomic configuration and a maximal cached-results record",
366+ scenario: "recorded steady-state request/result plus every maximum directional dictionary and maximum storage records",
300367 capacities,
301368 budget,
302369 pass: messages.length <= budget.steadyStateMessages
370+ && allMaximumMessageTypesMeasured
371+ && maximumPhoneDictionariesFitInbox
372+ && maximumWatchRequestsFitOutbox
303373 && bytes <= budget.steadyStateEncodedBytes
304374 && sizes[0] <= budget.requestBytes
305375 && largestPhoneDictionaryBytes <= budget.phoneDictionaryBytes
@@ -5,6 +5,10 @@ const require = createRequire(import.meta.url);
5 const codec = require("../packages/companion/src/codec.js");5 const codec = require("../packages/companion/src/codec.js");
6 const contracts = require("../packages/companion/src/contracts.js");6 const contracts = require("../packages/companion/src/contracts.js");
7 const configuration = require("../packages/companion/src/configuration.js");7 const configuration = require("../packages/companion/src/configuration.js");
8+import {
9+ APP_MESSAGE_INBOX_BYTES,
10+ APP_MESSAGE_OUTBOX_BYTES,
11+} from "../packages/watch/src/embeddedjs/contracts.js";
8 import {12 import {
9 serializeWatchConfiguration,13 serializeWatchConfiguration,
10 WATCH_CONFIGURATION_MAX_BYTES,14 WATCH_CONFIGURATION_MAX_BYTES,
@@ -24,6 +28,21 @@ function dictionarySize(dictionary) {
24 return 1 + (7 * sizes.length) + sizes.reduce((total, size) => total + size, 0);28 return 1 + (7 * sizes.length) + sizes.reduce((total, size) => total + size, 0);
25 }29 }
26 30
31+function dictionaryMaximumsByMessageType(messages) {
32+ const maximums = Object.fromEntries(
33+ Object.keys(contracts.MESSAGE_TYPE).map((name) => [name, null]),
34+ );
35+ for (const message of messages) {
36+ const name = Object.keys(contracts.MESSAGE_TYPE).find(
37+ (candidate) => contracts.MESSAGE_TYPE[candidate] === message.MESSAGE_TYPE,
38+ );
39+ if (name === undefined) throw new Error("codec emitted an unknown message type");
40+ const size = dictionarySize(message);
41+ maximums[name] = maximums[name] === null ? size : Math.max(maximums[name], size);
42+ }
43+ return maximums;
44+}
45+
27 function maximumEncodedString(byteLimit, suffix = "") {46 function maximumEncodedString(byteLimit, suffix = "") {
28 return "\\".repeat(byteLimit - Buffer.byteLength(suffix, "utf8")) + suffix;47 return "\\".repeat(byteLimit - Buffer.byteLength(suffix, "utf8")) + suffix;
29 }48 }
@@ -33,22 +52,8 @@ function longestLabel(values) {
33 return values.reduce((longest, value) => (value.length > longest.length ? value : longest));52 return values.reduce((longest, value) => (value.length > longest.length ? value : longest));
34 }53 }
35 54
36-// AppMessage capacity actually configured by the watch app. The watch55+// An explicit runtime bound prevents an 8k-capable phone connection from
37-// bootstrap (packages/watch/src/embeddedjs/main.js) constructs56+// expanding both native Pebble buffers to 8200 bytes.
38-// `new Message({ keys: APP_MESSAGE_KEY_MAP, ... })` with no input/output
39-// bound, so the Moddable bridge
40-// (RePebble SDK 4.33.1 toolchain/moddable/build/devices/pebble/modules/
41-// message/pebble-appmessage.c) opens
42-// app_message_open(app_message_inbox_size_maximum(),
43-// app_message_outbox_size_maximum()). On the SDK 4.33.1 firmware those
44-// return 2026 (0x7ea) and 654 (0x28e) bytes for a JS-allowed app; both
45-// constants disassemble identically from the emery and gabbro qemu
46-// *_sdk_debug.elf images. A phone companion advertising the 8k message
47-// capability lifts both maxima to 8200 (0x2008), which this budget does not
48-// assume. AppMessage buffers live in the application heap (pebble.h), inside
49-// the 56 KiB XS arena budget enforced at compile time in mdbl.c.
50-const appMessageInboxBytes = 2026;
51-const appMessageOutboxBytes = 654;
52 const appMessageEightKilobyteBytes = 8200;57 const appMessageEightKilobyteBytes = 8200;
53 58
54 // Maximal frozen DepartureResult bounds from the contracts validators59 // Maximal frozen DepartureResult bounds from the contracts validators
@@ -68,10 +73,10 @@ const maximumDeparture = {
68 nextIntervalMinutes: 1440,73 nextIntervalMinutes: 1440,
69 };74 };
70 75
71-function maximumResult(favoriteId) {76+function maximumResult(requestId, favoriteId) {
72 return {77 return {
73 schemaVersion: contracts.SCHEMA_VERSION,78 schemaVersion: contracts.SCHEMA_VERSION,
74- requestId: maximumEncodedString(contracts.LIMITS.idUtf8Bytes),79+ requestId,
75 favoriteId,80 favoriteId,
76 fetchedAt: UINT32_MAX,81 fetchedAt: UINT32_MAX,
77 sourceUpdatedAt: UINT32_MAX,82 sourceUpdatedAt: UINT32_MAX,
@@ -83,12 +88,8 @@ function maximumResult(favoriteId) {
83 };88 };
84 }89 }
85 90
86-const request = codec.encodeRequest({91+const maximumRequestId = maximumEncodedString(contracts.LIMITS.idUtf8Bytes, "request");
87- requestId: fixture.result.requestId,92+const maximumSequenceId = maximumEncodedString(contracts.LIMITS.idUtf8Bytes, "sequence");
88- favoriteId: fixture.favorite.id,
89- trigger: contracts.REQUEST_TRIGGER.APP_OPEN,
90-});
91-const response = codec.encodeResult(fixture.result);
92 const maximumApiKey = maximumEncodedString(contracts.LIMITS.apiKeyUtf8Bytes);93 const maximumApiKey = maximumEncodedString(contracts.LIMITS.apiKeyUtf8Bytes);
93 const maximumFavorites = Array.from({ length: contracts.LIMITS.favorites }, (_, index) => {94 const maximumFavorites = Array.from({ length: contracts.LIMITS.favorites }, (_, index) => {
94 const suffix = String(index).padStart(2, "0");95 const suffix = String(index).padStart(2, "0");
@@ -103,12 +104,45 @@ const maximumFavorites = Array.from({ length: contracts.LIMITS.favorites }, (_,
103 sortOrder: index,104 sortOrder: index,
104 };105 };
105 });106 });
107+const maximumFavoriteId = maximumFavorites[0].id;
108+const maximumRequestShape = {
109+ requestId: maximumRequestId,
110+ favoriteId: maximumFavoriteId,
111+ trigger: contracts.REQUEST_TRIGGER.MANUAL_SELECT,
112+};
113+const maximumDepartureResult = maximumResult(maximumRequestId, maximumFavoriteId);
114+const maximumErrorResult = {
115+ schemaVersion: contracts.SCHEMA_VERSION,
116+ requestId: maximumRequestId,
117+ favoriteId: maximumFavoriteId,
118+ code: longestLabel(contracts.ERROR_CODE),
119+ occurredAt: UINT32_MAX,
120+ retryAfterSeconds: UINT32_MAX,
121+};
122+
123+const request = codec.encodeRequest({
124+ requestId: fixture.result.requestId,
125+ favoriteId: fixture.favorite.id,
126+ trigger: contracts.REQUEST_TRIGGER.APP_OPEN,
127+});
128+const response = codec.encodeResult(fixture.result);
129+const maximumWatchRequest = codec.encodeRequest(maximumRequestShape);
130+const maximumMirroredRequest = codec.encodeRequest(maximumRequestShape);
106 const configurationMessages = codec.encodeConfiguration(131 const configurationMessages = codec.encodeConfiguration(
107- "config-maximum",132+ maximumSequenceId,
108 maximumFavorites,133 maximumFavorites,
109 contracts.KEY_STATUS.CONFIGURED,134 contracts.KEY_STATUS.CONFIGURED,
110 contracts.WIRE_LANGUAGE.FR,135 contracts.WIRE_LANGUAGE.FR,
111 );136 );
137+const maximumResultMessages = codec.encodeResult(maximumDepartureResult);
138+const maximumErrorMessage = codec.encodeError(maximumErrorResult);
139+const maximumPhoneToWatchMessages = [
140+ ...configurationMessages,
141+ ...maximumResultMessages,
142+ maximumMirroredRequest,
143+ maximumErrorMessage,
144+];
145+const maximumWatchToPhoneMessages = [maximumWatchRequest];
112 146
113 // Maximal lapinFuteResults record per the configuration validators147 // Maximal lapinFuteResults record per the configuration validators
114 // (packages/companion/src/configuration.js isStoredResults/isResultEntry):148 // (packages/companion/src/configuration.js isStoredResults/isResultEntry):
@@ -118,7 +152,7 @@ const configurationMessages = codec.encodeConfiguration(
118 const maximumResultEntries = maximumFavorites.map((favorite) => ({152 const maximumResultEntries = maximumFavorites.map((favorite) => ({
119 favoriteId: favorite.id,153 favoriteId: favorite.id,
120 storedAt: Number.MAX_SAFE_INTEGER,154 storedAt: Number.MAX_SAFE_INTEGER,
121- result: maximumResult(favorite.id),155+ result: maximumResult(maximumRequestId, favorite.id),
122 }));156 }));
123 if (!maximumResultEntries.every((entry) => contracts.isDepartureResult(entry.result))) {157 if (!maximumResultEntries.every((entry) => contracts.isDepartureResult(entry.result))) {
124 throw new Error("maximum result must satisfy the frozen DepartureResult contract");158 throw new Error("maximum result must satisfy the frozen DepartureResult contract");
@@ -131,6 +165,23 @@ const bytes = sizes.reduce((total, size) => total + size, 0);
131 const largestPhoneDictionaryBytes = Math.max(...sizes.slice(1));165 const largestPhoneDictionaryBytes = Math.max(...sizes.slice(1));
132 const largestConfigurationDictionaryBytes = Math.max(...configurationSizes);166 const largestConfigurationDictionaryBytes = Math.max(...configurationSizes);
133 const configurationEncodedBytes = configurationSizes.reduce((total, size) => total + size, 0);167 const configurationEncodedBytes = configurationSizes.reduce((total, size) => total + size, 0);
168+const maximumPhoneToWatchSizes = maximumPhoneToWatchMessages.map(dictionarySize);
169+const maximumWatchToPhoneSizes = maximumWatchToPhoneMessages.map(dictionarySize);
170+const maximumDictionaryBytesByMessageType = dictionaryMaximumsByMessageType([
171+ ...maximumWatchToPhoneMessages,
172+ ...maximumPhoneToWatchMessages,
173+]);
174+const allMaximumMessageTypesMeasured = Object.values(
175+ maximumDictionaryBytesByMessageType,
176+).every((size) => size !== null);
177+const maximumPhoneDictionariesFitInbox = maximumPhoneToWatchSizes.every(
178+ (size) => size <= APP_MESSAGE_INBOX_BYTES,
179+);
180+const maximumWatchRequestsFitOutbox = maximumWatchToPhoneSizes.every(
181+ (size) => size <= APP_MESSAGE_OUTBOX_BYTES,
182+);
183+const largestMaximumPhoneDictionaryBytes = Math.max(...maximumPhoneToWatchSizes);
184+const largestMaximumWatchDictionaryBytes = Math.max(...maximumWatchToPhoneSizes);
134 const phoneRecordBytes = Buffer.byteLength(JSON.stringify({185 const phoneRecordBytes = Buffer.byteLength(JSON.stringify({
135 schemaVersion: contracts.SCHEMA_VERSION,186 schemaVersion: contracts.SCHEMA_VERSION,
136 favorites: maximumFavorites,187 favorites: maximumFavorites,
@@ -198,12 +249,12 @@ const capacities = {
198 source: "README.md quality gates: two-departure refresh at most 2048 encoded bytes",249 source: "README.md quality gates: two-departure refresh at most 2048 encoded bytes",
199 },250 },
200 requestBytes: {251 requestBytes: {
201- value: 192,252+ value: APP_MESSAGE_OUTBOX_BYTES,
202- source: "README.md quality gates: watch request at most 192 encoded bytes; travels watch-to-phone through the 654-byte outbox",253+ source: "README.md watch-request gate, sourced from APP_MESSAGE_OUTBOX_BYTES and passed to main.js Message output",
203 },254 },
204 phoneDictionaryBytes: {255 phoneDictionaryBytes: {
205- value: 768,256+ value: APP_MESSAGE_INBOX_BYTES,
206- source: "README.md quality gates: phone-to-watch dictionary at most 768 encoded bytes; the frozen repository budget, tighter than the 2026-byte watch inbox actually configured by the watch app",257+ source: "README.md phone-to-watch dictionary gate, sourced from APP_MESSAGE_INBOX_BYTES and passed to main.js Message input",
207 },258 },
208 configurationMessages: {259 configurationMessages: {
209 value: contracts.LIMITS.favorites + 2,260 value: contracts.LIMITS.favorites + 2,
@@ -211,19 +262,19 @@ const capacities = {
211 },262 },
212 configurationEncodedBytes: {263 configurationEncodedBytes: {
213 value: 8192,264 value: 8192,
214- source: "frozen v1 configuration-batch budget carried from the foundation measurement plan in this script (power-of-two batch headroom); the binding per-dictionary gate is appMessageInboxBytes",265+ source: "frozen v1 configuration-batch budget carried from the foundation measurement plan in this script (power-of-two batch headroom); the binding per-dictionary gate is APP_MESSAGE_INBOX_BYTES",
215 },266 },
216 appMessageInboxBytes: {267 appMessageInboxBytes: {
217- value: appMessageInboxBytes,268+ value: APP_MESSAGE_INBOX_BYTES,
218- source: "watch inbox capacity configured by the watch app: main.js passes no Message input bound, so Moddable pebble-appmessage.c opens app_message_open(app_message_inbox_size_maximum(), app_message_outbox_size_maximum()); RePebble SDK 4.33.1 emery/gabbro firmware returns 2026 bytes (0x7ea) for a JS-allowed app (disassembled from both qemu *_sdk_debug.elf images; 126 bytes for non-JS apps)",269+ source: "packages/watch/src/embeddedjs/contracts.js APP_MESSAGE_INBOX_BYTES, passed to main.js Message input",
219 },270 },
220 appMessageOutboxBytes: {271 appMessageOutboxBytes: {
221- value: appMessageOutboxBytes,272+ value: APP_MESSAGE_OUTBOX_BYTES,
222- source: "watch outbox capacity from app_message_outbox_size_maximum() in the same RePebble SDK 4.33.1 emery/gabbro firmware images: 654 bytes (0x28e) without the 8k phone capability",273+ source: "packages/watch/src/embeddedjs/contracts.js APP_MESSAGE_OUTBOX_BYTES, passed to main.js Message output",
223 },274 },
224 appMessageEightKilobyteBytes: {275 appMessageEightKilobyteBytes: {
225 value: appMessageEightKilobyteBytes,276 value: appMessageEightKilobyteBytes,
226- source: "phone companions advertising the 8k message capability (prv_supports_8k) lift both AppMessage maxima to 8200 bytes (0x2008); not assumed by this budget",277+ source: "phone companions advertising the 8k message capability can expose 8200-byte maxima, but explicit Message input/output bounds prevent those native allocations",
227 },278 },
228 watchUnsentQueueMessages: {279 watchUnsentQueueMessages: {
229 value: 4,280 value: 4,
@@ -231,7 +282,7 @@ const capacities = {
231 },282 },
232 xsArenaBudgetBytes: {283 xsArenaBudgetBytes: {
233 value: 56 * 1024,284 value: 56 * 1024,
234- source: "mdbl.c XS_STACK_BYTES + XS_SLOT_BYTES + XS_CHUNK_BYTES = 56 KiB enforced by a compile-time #error; README loaded image and RAM gate with XS arenas at least 25% free; AppMessage inbox/outbox buffers are allocated from this application heap (pebble.h)",285+ source: "mdbl.c XS_STACK_BYTES + XS_SLOT_BYTES + XS_CHUNK_BYTES = 56 KiB enforced by a compile-time #error; native AppMessage inbox/outbox buffers are separate allocations from the same application heap, in addition to the XS arena total",
235 },286 },
236 phoneRecordBytes: {287 phoneRecordBytes: {
237 value: 16384,288 value: 16384,
@@ -276,6 +327,22 @@ const report = {
276 requestBytes: sizes[0],327 requestBytes: sizes[0],
277 largestPhoneDictionaryBytes,328 largestPhoneDictionaryBytes,
278 },329 },
330+ maximumDictionaries: {
331+ bytesByMessageType: maximumDictionaryBytesByMessageType,
332+ phoneToWatch: {
333+ messages: maximumPhoneToWatchMessages.length,
334+ includesMirroredRequest: true,
335+ inboxBytes: APP_MESSAGE_INBOX_BYTES,
336+ largestDictionaryBytes: largestMaximumPhoneDictionaryBytes,
337+ allFit: maximumPhoneDictionariesFitInbox,
338+ },
339+ watchToPhone: {
340+ messages: maximumWatchToPhoneMessages.length,
341+ outboxBytes: APP_MESSAGE_OUTBOX_BYTES,
342+ largestDictionaryBytes: largestMaximumWatchDictionaryBytes,
343+ allFit: maximumWatchRequestsFitOutbox,
344+ },
345+ },
279 maximumConfiguration: {346 maximumConfiguration: {
280 messages: configurationMessages.length,347 messages: configurationMessages.length,
281 encodedBytes: configurationEncodedBytes,348 encodedBytes: configurationEncodedBytes,
@@ -296,10 +363,13 @@ const report = {
296 invalidKeyStatusRecordBytes,363 invalidKeyStatusRecordBytes,
297 totalBytes: phoneStorageBytes,364 totalBytes: phoneStorageBytes,
298 },365 },
299- scenario: "steady-state request/result plus a complete maximum-favorite atomic configuration and a maximal cached-results record",366+ scenario: "recorded steady-state request/result plus every maximum directional dictionary and maximum storage records",
300 capacities,367 capacities,
301 budget,368 budget,
302 pass: messages.length <= budget.steadyStateMessages369 pass: messages.length <= budget.steadyStateMessages
370+ && allMaximumMessageTypesMeasured
371+ && maximumPhoneDictionariesFitInbox
372+ && maximumWatchRequestsFitOutbox
303 && bytes <= budget.steadyStateEncodedBytes373 && bytes <= budget.steadyStateEncodedBytes
304 && sizes[0] <= budget.requestBytes374 && sizes[0] <= budget.requestBytes
305 && largestPhoneDictionaryBytes <= budget.phoneDictionaryBytes375 && largestPhoneDictionaryBytes <= budget.phoneDictionaryBytes
modified scripts/test.mjs +1 -0
@@ -25,5 +25,6 @@ run(process.execPath, [
2525 "packages/watch/test/model.test.js",
2626 "packages/watch/test/storage.test.js",
2727 "packages/watch/test/localization.test.js",
28+ "packages/watch/test/presentation.test.js",
2829 "packages/watch/test/fixture-boot.test.js",
2930 ]);
@@ -25,5 +25,6 @@ run(process.execPath, [
25 "packages/watch/test/model.test.js",25 "packages/watch/test/model.test.js",
26 "packages/watch/test/storage.test.js",26 "packages/watch/test/storage.test.js",
27 "packages/watch/test/localization.test.js",27 "packages/watch/test/localization.test.js",
28+ "packages/watch/test/presentation.test.js",
28 "packages/watch/test/fixture-boot.test.js",29 "packages/watch/test/fixture-boot.test.js",
29 ]);30 ]);