feat: deliver Pebble watch consultation (#15)Unverified
aabca5b parent: 5ff79d1 modified
packages/companion/src/index.js +273 -104 | @@ -38,16 +38,28 @@ function normalizeIncomingPayload(payload) { | ||
| 38 | 38 | } |
| 39 | 39 | return aliasCount > 0 ? normalized : null; |
| 40 | 40 | } |
| 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 | + }); | |
| 48 | 57 | } |
| 49 | 58 | |
| 59 | + | |
| 60 | + | |
| 50 | 61 | function Companion(options) { |
| 62 | + var self = this; | |
| 51 | 63 | options = options || {}; |
| 52 | 64 | requiredAdapter(options.Pebble && typeof options.Pebble.addEventListener === "function", "Pebble adapter is required"); |
| 53 | 65 | requiredAdapter(options.storage |
| @@ -58,18 +70,15 @@ function Companion(options) { | ||
| 58 | 70 | requiredAdapter(options.clock |
| 59 | 71 | && typeof options.clock.now === "function", "clock adapter is required"); |
| 60 | 72 | 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"); | |
| 64 | 74 | |
| 65 | 75 | this._Pebble = options.Pebble; |
| 66 | 76 | this._storage = options.storage; |
| 67 | 77 | this._XHR = options.XHR; |
| 68 | 78 | this._clock = options.clock; |
| 79 | + this._readyDefer = options.readyDefer; | |
| 69 | 80 | this._backendUrl = typeof options.backendUrl === "string" ? options.backendUrl : ""; |
| 70 | 81 | this._configurationUrl = typeof options.configurationUrl === "string" ? options.configurationUrl : ""; |
| 71 | - this._fixture = options.fixture || null; | |
| 72 | - this._fixtureEnabled = false; | |
| 73 | 82 | this._configuration = configuration.loadConfiguration(this._storage); |
| 74 | 83 | this._configurationValid = this._configuration !== null; |
| 75 | 84 | this._credentialBlocked = this._configurationValid |
| @@ -86,12 +95,18 @@ function Companion(options) { | ||
| 86 | 95 | totalLatencyMs: 0, |
| 87 | 96 | lastLatencyMs: 0 |
| 88 | 97 | }; |
| 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; | |
| 92 | 105 | this._sequence = 0; |
| 93 | 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 | 110 | this._handlers = null; |
| 96 | 111 | this._refreshWatchState(); |
| 97 | 112 | } |
| @@ -114,7 +129,6 @@ Companion.prototype.start = function () { | ||
| 114 | 129 | |
| 115 | 130 | Companion.prototype.stop = function () { |
| 116 | 131 | var self = this; |
| 117 | - var inFlightRequests; | |
| 118 | 132 | if (!this._started) return; |
| 119 | 133 | if (typeof this._Pebble.removeEventListener === "function") { |
| 120 | 134 | Object.keys(this._handlers).forEach(function (eventName) { |
| @@ -122,26 +136,80 @@ Companion.prototype.stop = function () { | ||
| 122 | 136 | }); |
| 123 | 137 | } |
| 124 | 138 | 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; | |
| 128 | 199 | 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; | |
| 132 | 205 | try { |
| 133 | - xhr.abort(); | |
| 206 | + flight.xhr.abort(); | |
| 134 | 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 | 214 | Companion.prototype._nextSequenceId = function () { |
| 147 | 215 | this._sequence += 1; |
| @@ -150,17 +218,16 @@ Companion.prototype._nextSequenceId = function () { | ||
| 150 | 218 | }; |
| 151 | 219 | |
| 152 | 220 | 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; | |
| 158 | 224 | if (!this._configurationValid |
| 159 | 225 | || !configuration.areFavoritesSecretFree( |
| 160 | 226 | this._watchFavorites, |
| 161 | 227 | this._configuration.primApiKey, |
| 162 | 228 | null |
| 163 | 229 | )) return; |
| 230 | + this._resetPresentation(); | |
| 164 | 231 | this._queue.enqueue(codec.encodeConfiguration( |
| 165 | 232 | this._nextSequenceId(), |
| 166 | 233 | this._watchFavorites, |
| @@ -170,6 +237,15 @@ Companion.prototype._sendConfiguration = function () { | ||
| 170 | 237 | }; |
| 171 | 238 | |
| 172 | 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 | 249 | var wasBlocked = this._credentialBlocked; |
| 174 | 250 | var loaded = configuration.loadConfiguration(this._storage); |
| 175 | 251 | if (loaded === null) { |
| @@ -234,9 +310,9 @@ Companion.prototype._onWebviewClosed = function (event) { | ||
| 234 | 310 | if (JSON.stringify(pruned) !== JSON.stringify(this._results)) { |
| 235 | 311 | configuration.saveResults(this._storage, pruned); |
| 236 | 312 | } |
| 313 | + this._invalidateLifecycle(); | |
| 237 | 314 | this._configuration = next; |
| 238 | 315 | this._results = pruned; |
| 239 | - this._requestGenerations = Object.create(null); | |
| 240 | 316 | this._credentialBlocked = next.primApiKey !== null |
| 241 | 317 | && next.keyStatus === contracts.KEY_STATUS.INVALID; |
| 242 | 318 | this._refreshWatchState(); |
| @@ -244,15 +320,25 @@ Companion.prototype._onWebviewClosed = function (event) { | ||
| 244 | 320 | }; |
| 245 | 321 | |
| 246 | 322 | Companion.prototype._onAppMessage = function (event) { |
| 323 | + var favorite; | |
| 324 | + var decoded; | |
| 247 | 325 | var request; |
| 248 | 326 | 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) { | |
| 252 | 338 | this._sendError(request, "INVALID_SERVICE"); |
| 253 | 339 | return; |
| 254 | 340 | } |
| 255 | - this._dispatchRequest(request); | |
| 341 | + this._dispatchRequest(request, favorite); | |
| 256 | 342 | }; |
| 257 | 343 | |
| 258 | 344 | Companion.prototype._findFavorite = function (favoriteId) { |
| @@ -280,40 +366,83 @@ Companion.prototype._cachedResult = function (request) { | ||
| 280 | 366 | return configuration.findResult(this._results, request.favoriteId, request.requestId); |
| 281 | 367 | }; |
| 282 | 368 | |
| 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; | |
| 287 | 372 | }; |
| 288 | 373 | |
| 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; | |
| 291 | 430 | }; |
| 292 | 431 | |
| 293 | -Companion.prototype._dispatchRequest = function (request) { | |
| 432 | +Companion.prototype._dispatchRequest = function (request, favorite) { | |
| 294 | 433 | var cached = this._cachedResult(request); |
| 295 | - var cachedMessages; | |
| 296 | - var fixtureResult; | |
| 297 | - var favorite; | |
| 298 | - var generation; | |
| 434 | + var flight; | |
| 299 | 435 | var age; |
| 300 | 436 | var refreshRequired; |
| 301 | 437 | if (cached !== null) { |
| 302 | 438 | this._metrics.cacheHits += 1; |
| 303 | 439 | age = this._clock.now() - Math.min(cached.result.fetchedAt * 1000, cached.storedAt); |
| 304 | 440 | 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); | |
| 308 | 442 | if (!refreshRequired) return; |
| 309 | 443 | } else { |
| 310 | 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 | 446 | if (this._configuration.primApiKey === null) { |
| 318 | 447 | this._sendError(request, "API_KEY_REQUIRED"); |
| 319 | 448 | return; |
| @@ -323,23 +452,44 @@ Companion.prototype._dispatchRequest = function (request) { | ||
| 323 | 452 | this._sendError(request, "API_KEY_INVALID"); |
| 324 | 453 | return; |
| 325 | 454 | } |
| 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; | |
| 329 | 458 | return; |
| 330 | 459 | } |
| 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); | |
| 333 | 462 | }; |
| 334 | 463 | |
| 335 | -Companion.prototype._fetch = function (request, favorite, generation) { | |
| 464 | +Companion.prototype._fetch = function (request, favorite) { | |
| 336 | 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 | 473 | var xhr; |
| 474 | + var flight; | |
| 475 | + var failedRequest; | |
| 338 | 476 | var settled = false; |
| 339 | 477 | var key = null; |
| 340 | 478 | var startedAt = this._clock.now(); |
| 341 | 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 | 493 | return; |
| 344 | 494 | } |
| 345 | 495 | |
| @@ -348,58 +498,62 @@ Companion.prototype._fetch = function (request, favorite, generation) { | ||
| 348 | 498 | var latency; |
| 349 | 499 | if (settled) return; |
| 350 | 500 | settled = true; |
| 501 | + if (generation !== self._lifecycleGeneration | |
| 502 | + || self._inFlightByFavorite[launchRequest.favoriteId] !== flight) return; | |
| 503 | + delete self._inFlightByFavorite[launchRequest.favoriteId]; | |
| 351 | 504 | latency = Math.max(0, self._clock.now() - startedAt); |
| 352 | 505 | self._metrics.lastLatencyMs = latency; |
| 353 | 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 | 507 | callback(); |
| 359 | 508 | }; |
| 360 | 509 | } |
| 361 | 510 | |
| 362 | 511 | try { |
| 363 | 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 | 520 | xhr.open("POST", this._backendUrl, true); |
| 365 | 521 | xhr.timeout = contracts.LIMITS.httpTimeoutMs; |
| 366 | 522 | xhr.setRequestHeader("Content-Type", "application/json"); |
| 367 | 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 | 525 | xhr.onerror = once(function () { |
| 370 | 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 | 532 | xhr.ontimeout = xhr.onerror; |
| 374 | 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 | 534 | xhr.setRequestHeader("Authorization", "Bearer " + key); |
| 383 | 535 | this._metrics.requests += 1; |
| 384 | - this._inFlightRequests[generation] = xhr; | |
| 385 | 536 | xhr.send(JSON.stringify({ |
| 386 | 537 | 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 | |
| 390 | 541 | })); |
| 391 | 542 | key = null; |
| 392 | 543 | } catch (ignored) { |
| 544 | + failedRequest = request; | |
| 393 | 545 | 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; | |
| 396 | 551 | } |
| 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"); | |
| 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 | 577 | var body = this._responseJson(xhr); |
| 420 | 578 | var status = Number(xhr.status); |
| 579 | + var result; | |
| 421 | 580 | var error; |
| 422 | 581 | if (status >= 200 && status < 300) { |
| 423 | 582 | if (!contracts.isDepartureResult(body) |
| 424 | - || body.requestId !== request.requestId | |
| 425 | - || body.favoriteId !== request.favoriteId) { | |
| 583 | + || body.requestId !== launchRequest.requestId | |
| 584 | + || body.favoriteId !== launchRequest.favoriteId) { | |
| 426 | 585 | this._metrics.failures += 1; |
| 427 | - this._sendError(request, "INVALID_RESPONSE"); | |
| 586 | + if (current) this._sendError(latestRequest, "INVALID_RESPONSE"); | |
| 428 | 587 | return; |
| 429 | 588 | } |
| 430 | 589 | this._metrics.successes += 1; |
| 431 | 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 | 594 | return; |
| 434 | 595 | } |
| 435 | 596 | |
| 436 | 597 | this._metrics.failures += 1; |
| 598 | + if (!current) return; | |
| 437 | 599 | if (status === 401 || status === 403) { |
| 438 | - this._sendError(request, "API_KEY_INVALID"); | |
| 600 | + this._sendError(latestRequest, "API_KEY_INVALID"); | |
| 439 | 601 | return; |
| 440 | 602 | } |
| 441 | 603 | 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"); | |
| 445 | 608 | return; |
| 446 | 609 | } |
| 447 | 610 | error = { |
| 448 | 611 | schemaVersion: contracts.SCHEMA_VERSION, |
| 449 | - requestId: request.requestId, | |
| 450 | - favoriteId: request.favoriteId, | |
| 612 | + requestId: latestRequest.requestId, | |
| 613 | + favoriteId: latestRequest.favoriteId, | |
| 451 | 614 | code: body.code, |
| 452 | 615 | 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 | 620 | this._sendErrorResult(error); |
| 456 | 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 | 631 | 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.storage | 65 | requiredAdapter(options.storage |
| @@ -58,18 +70,15 @@ function Companion(options) { | |||
| 58 | requiredAdapter(options.clock | 70 | 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._configurationValid | 84 | this._credentialBlocked = this._configurationValid |
| @@ -86,12 +95,18 @@ function Companion(options) { | |||
| 86 | totalLatencyMs: 0, | 95 | totalLatencyMs: 0, |
| 87 | lastLatencyMs: 0 | 96 | 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._fixtureEnabled | 221 | + var keyStatus = this._credentialBlocked |
| 154 | - ? contracts.KEY_STATUS.CONFIGURED | 222 | + ? contracts.KEY_STATUS.INVALID |
| 155 | - : (this._credentialBlocked | 223 | + : this._configuration.keyStatus; |
| 156 | - ? contracts.KEY_STATUS.INVALID | ||
| 157 | - : this._configuration.keyStatus); | ||
| 158 | if (!this._configurationValid | 224 | if (!this._configurationValid |
| 159 | || !configuration.areFavoritesSecretFree( | 225 | || !configuration.areFavoritesSecretFree( |
| 160 | this._watchFavorites, | 226 | this._watchFavorites, |
| 161 | this._configuration.primApiKey, | 227 | this._configuration.primApiKey, |
| 162 | null | 228 | 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 !== null | 316 | 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.serviceId | 540 | + 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.requestId | 583 | + || 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.requestId | 604 | + 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.occurredAt | 615 | 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) { | ||
| 11 | 11 | this._batches = []; |
| 12 | 12 | this._active = null; |
| 13 | 13 | this._sending = false; |
| 14 | + this._completing = false; | |
| 14 | 15 | this._deferredGeneration = null; |
| 15 | 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 | 22 | if (!Array.isArray(messages) || messages.length === 0) { |
| 20 | 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 | 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 | 52 | MessageQueue.prototype.clear = function () { |
| @@ -54,13 +80,16 @@ MessageQueue.prototype._scheduleAdvance = function (generation) { | ||
| 54 | 80 | |
| 55 | 81 | MessageQueue.prototype._advance = function () { |
| 56 | 82 | var self = this; |
| 83 | + var completed; | |
| 57 | 84 | var message; |
| 58 | 85 | 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 | + } | |
| 61 | 90 | if (!this._active) return; |
| 62 | 91 | |
| 63 | - message = this._active[0]; | |
| 92 | + message = this._active.messages[0]; | |
| 64 | 93 | this._sending = true; |
| 65 | 94 | generation = this._generation; |
| 66 | 95 | try { |
| @@ -72,8 +101,26 @@ MessageQueue.prototype._advance = function () { | ||
| 72 | 101 | } |
| 73 | 102 | return; |
| 74 | 103 | } |
| 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 | + } | |
| 77 | 124 | if (self._active !== null || self._batches.length > 0) { |
| 78 | 125 | self._scheduleAdvance(generation); |
| 79 | 126 | } |
| @@ -89,9 +136,9 @@ MessageQueue.prototype._advance = function () { | ||
| 89 | 136 | self._onFailure("APP_MESSAGE_FAILED"); |
| 90 | 137 | }); |
| 91 | 138 | } 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"); | |
| 95 | 142 | } |
| 96 | 143 | }; |
| 97 | 144 | |
| @@ -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 @@ | ||
| 3 | 3 | var test = require("node:test"); |
| 4 | 4 | var assert = require("node:assert/strict"); |
| 5 | 5 | var vm = require("node:vm"); |
| 6 | -var path = require("node:path"); | |
| 7 | -var url = require("node:url"); | |
| 8 | 6 | var companion = require("../src"); |
| 9 | 7 | var fixture = require("../../../fixtures/departures/foundation.json"); |
| 10 | 8 | var fakes = require("./fakes"); |
| @@ -14,6 +12,13 @@ var codec = companion.codec; | ||
| 14 | 12 | var configuration = companion.configuration; |
| 15 | 13 | var T = contracts.MESSAGE_TYPE; |
| 16 | 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 | 23 | function harness(options) { |
| 19 | 24 | var Pebble; |
| @@ -21,6 +26,7 @@ function harness(options) { | ||
| 21 | 26 | var clock; |
| 22 | 27 | var xhr; |
| 23 | 28 | var defer; |
| 29 | + var readyDefer; | |
| 24 | 30 | var instance; |
| 25 | 31 | options = options || {}; |
| 26 | 32 | Pebble = options.Pebble || new fakes.FakePebble(true); |
| @@ -28,30 +34,34 @@ function harness(options) { | ||
| 28 | 34 | clock = options.clock || new fakes.FakeClock(); |
| 29 | 35 | xhr = fakes.createXHRFactory(); |
| 30 | 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 | 40 | instance = companion.createCompanion({ |
| 32 | 41 | Pebble: Pebble, |
| 33 | 42 | storage: storage, |
| 34 | - XHR: xhr.XHR, | |
| 43 | + XHR: options.XHR || xhr.XHR, | |
| 35 | 44 | clock: clock, |
| 36 | 45 | defer: defer, |
| 46 | + readyDefer: readyDefer, | |
| 37 | 47 | 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" | |
| 40 | 49 | }); |
| 41 | 50 | return { |
| 42 | 51 | Pebble: Pebble, |
| 43 | 52 | storage: storage, |
| 44 | 53 | clock: clock, |
| 45 | 54 | defer: defer, |
| 55 | + readyDefer: readyDefer, | |
| 46 | 56 | xhr: xhr, |
| 47 | 57 | companion: instance |
| 48 | 58 | }; |
| 49 | 59 | } |
| 50 | 60 | |
| 51 | -function configurationPayload(action, key) { | |
| 61 | +function configurationPayload(action, key, favorites) { | |
| 52 | 62 | var update = { |
| 53 | 63 | schemaVersion: contracts.SCHEMA_VERSION, |
| 54 | - favorites: [fixture.favorite], | |
| 64 | + favorites: favorites || [fixture.favorite], | |
| 55 | 65 | apiKeyUpdate: { schemaVersion: contracts.SCHEMA_VERSION, action: action } |
| 56 | 66 | }; |
| 57 | 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 | 78 | target.Pebble.emit("appmessage", { |
| 69 | 79 | payload: codec.encodeRequest({ |
| 70 | 80 | requestId: requestId, |
| 71 | - favoriteId: fixture.favorite.id, | |
| 81 | + favoriteId: (favorite || fixture.favorite).id, | |
| 72 | 82 | 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 | 109 | var result = contracts.copyDepartureResult(fixture.result, requestId); |
| 110 | + result.favoriteId = (favorite || fixture.favorite).id; | |
| 100 | 111 | return result; |
| 101 | 112 | } |
| 102 | 113 | |
| 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; | |
| 106 | 126 | } |
| 107 | 127 | |
| 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; | |
| 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 | 148 | test("close fragment atomically stores one favorites-and-key record and exposes the key only to Authorization", function () { |
| 115 | 149 | var target = harness(); |
| 116 | 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 | 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 | 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 | 320 | assert.deepEqual(target.Pebble.sent.map(function (message) { return message.MESSAGE_TYPE; }), [ |
| 277 | 321 | T.CONFIG_BEGIN |
| 278 | 322 | ]); |
| @@ -294,16 +338,18 @@ test("configuration advances one event-loop turn at a time after immediate ACKs" | ||
| 294 | 338 | assert.equal(defer.pending.length, 0); |
| 295 | 339 | }); |
| 296 | 340 | |
| 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 | + }); | |
| 300 | 347 | |
| 301 | 348 | target.Pebble.emit("ready"); |
| 349 | + assert.deepEqual(target.Pebble.sent, []); | |
| 302 | 350 | 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, []); | |
| 307 | 353 | }); |
| 308 | 354 | |
| 309 | 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 | 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 () { | |
| 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(); | |
| 339 | 383 | |
| 340 | 384 | target.Pebble.emit("ready"); |
| 341 | 385 | assert.deepEqual(target.Pebble.sent.map(function (message) { return message.MESSAGE_TYPE; }), [ |
| 342 | 386 | T.CONFIG_BEGIN, |
| 343 | - T.FAVORITE, | |
| 344 | 387 | T.CONFIG_COMMIT |
| 345 | 388 | ]); |
| 346 | 389 | 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); | |
| 357 | 392 | assert.equal(target.xhr.instances.length, 0); |
| 358 | 393 | assert.equal(target.clock.timers.length, 0); |
| 359 | 394 | }); |
| 360 | 395 | |
| 361 | 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 | 398 | var payload = dualRequestPayload("dual-app-open"); |
| 364 | 399 | var before; |
| 365 | 400 | var resultBegins; |
| @@ -368,16 +403,18 @@ test("pypkjs dual request properties are canonicalized only at the event boundar | ||
| 368 | 403 | before = target.Pebble.sent.length; |
| 369 | 404 | assert.equal(codec.decodeRequest(payload), null); |
| 370 | 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 | 409 | assert.equal(target.Pebble.sent.length, before + fixture.result.departures.length + 2); |
| 373 | 410 | resultBegins = messagesOfType(target, T.RESULT_BEGIN); |
| 374 | 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 | 413 | assert.equal(target.clock.timers.length, 0); |
| 377 | 414 | }); |
| 378 | 415 | |
| 379 | 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 | 418 | var numericOnly = dualRequestPayload("numeric-only"); |
| 382 | 419 | var unknown = dualRequestPayload("unknown"); |
| 383 | 420 | var conflicting = dualRequestPayload("conflicting"); |
| @@ -411,83 +448,144 @@ test("incoming event normalization rejects numeric-only, unknown, conflicting, e | ||
| 411 | 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 | 497 | var target = harness(); |
| 416 | 498 | var before; |
| 417 | - var resultBegins; | |
| 418 | 499 | |
| 419 | 500 | closeWith(target, configurationPayload("REPLACE", TEST_KEY)); |
| 420 | 501 | sendRequest(target, "network-first"); |
| 421 | - assert.equal(target.xhr.instances.length, 1); | |
| 422 | 502 | target.xhr.instances[0].respond(200, responseFor("network-first")); |
| 423 | 503 | |
| 424 | 504 | target.clock.advance(59999); |
| 425 | 505 | before = target.Pebble.sent.length; |
| 426 | - sendRequest(target, "cache-hit"); | |
| 506 | + sendRequest(target, "fresh-suppressed", contracts.REQUEST_TRIGGER.MANUAL_SELECT); | |
| 427 | 507 | 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); | |
| 431 | 509 | |
| 432 | 510 | target.clock.advance(1); |
| 433 | - sendRequest(target, "cache-expired"); | |
| 511 | + sendRequest(target, "expired-suppressed", contracts.REQUEST_TRIGGER.FAVORITE_SELECTION); | |
| 434 | 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 | 517 | var target = harness(); |
| 440 | - var aged = responseFor("aged-network"); | |
| 518 | + var seed = responseFor("visible-seed"); | |
| 519 | + var unchanged; | |
| 520 | + var changed; | |
| 441 | 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 | 534 | 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); | |
| 454 | 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 () { | |
| 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"); | |
| 462 | 567 | var before; |
| 463 | - var statuses; | |
| 568 | + var index; | |
| 464 | 569 | |
| 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); | |
| 480 | 576 | |
| 481 | - fresh.fetchedAt += 60; | |
| 482 | 577 | 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); | |
| 491 | 589 | }); |
| 492 | 590 | |
| 493 | 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 | 844 | 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 | 848 | var target = harness(); |
| 751 | - var older = responseFor("older"); | |
| 752 | - var newer = responseFor("newer"); | |
| 753 | - var beforeOlder; | |
| 849 | + var outbound; | |
| 754 | 850 | var resultBegins; |
| 851 | + var live = responseFor("launch-request"); | |
| 755 | 852 | |
| 756 | - older.fetchedAt += 1; | |
| 757 | - newer.fetchedAt += 2; | |
| 853 | + live.fetchedAt += 1; | |
| 758 | 854 | 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 | + }); | |
| 762 | 865 | |
| 763 | - target.xhr.instances[1].respond(200, newer); | |
| 866 | + target.xhr.instances[0].respond(200, live); | |
| 764 | 867 | 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 | +}); | |
| 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 | 987 | 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 | + }); | |
| 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 | 1155 | test("HTTP failures map to stable error codes, keyStatus only, and never retry", function () { |
| 781 | 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 | 1199 | var target = harness(); |
| 825 | 1200 | var errors; |
| 826 | 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 | 1205 | target.xhr.instances[0].networkError(); |
| 829 | 1206 | errors = messagesOfType(target, T.ERROR); |
| 830 | 1207 | assert.equal(errors.length, 1); |
| 1208 | + assert.equal(errors[0].REQUEST_ID, "network-latest"); | |
| 831 | 1209 | assert.equal(errors[0].ERROR_CODE, contracts.ERROR_CODE.indexOf("SOURCE_UNAVAILABLE")); |
| 832 | 1210 | assert.equal(target.xhr.instances.length, 1); |
| 833 | 1211 | assert.equal(target.clock.timers.length, 0); |
| 834 | 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 | 1248 | var build = await import("../../../scripts/build.mjs"); |
| 838 | 1249 | var configured; |
| 839 | 1250 | var captured = null; |
| 840 | 1251 | var scheduled = []; |
| 841 | 1252 | var deferred = false; |
| 1253 | + var readyDeferred = false; | |
| 842 | 1254 | var source; |
| 843 | 1255 | |
| 844 | 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 | 1271 | LAPIN_FUTE_BACKEND_URL: "https://backend.example.test/departures", |
| 860 | 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 | 1275 | vm.runInNewContext(source, { |
| 864 | 1276 | Pebble: {}, |
| 865 | 1277 | localStorage: {}, |
| @@ -881,14 +1293,29 @@ test("build bootstrap validates URLs and injects only the zero-delay ACK defer", | ||
| 881 | 1293 | assert.notEqual(captured, null); |
| 882 | 1294 | assert.deepEqual(Object.keys(captured.clock), ["now"]); |
| 883 | 1295 | captured.defer(function () { deferred = true; }); |
| 1296 | + captured.readyDefer(function () { readyDeferred = true; }); | |
| 884 | 1297 | assert.equal(deferred, false); |
| 885 | - assert.equal(scheduled.length, 1); | |
| 1298 | + assert.equal(readyDeferred, false); | |
| 1299 | + assert.equal(scheduled.length, 2); | |
| 886 | 1300 | assert.equal(scheduled[0].delay, 0); |
| 1301 | + assert.equal(scheduled[1].delay, 250); | |
| 887 | 1302 | scheduled[0].callback(); |
| 1303 | + scheduled[1].callback(); | |
| 888 | 1304 | assert.equal(deferred, true); |
| 1305 | + assert.equal(readyDeferred, true); | |
| 889 | 1306 | assert.equal(captured.backendUrl, configured.backendUrl); |
| 890 | 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 | 1319 | assert.equal(source.includes("LAPIN_FUTE_"), false); |
| 893 | 1320 | assert.equal(source.includes(TEST_KEY), false); |
| 894 | 1321 | }); |
| @@ -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: instance | 57 | 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_OPEN | 82 | 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_BEGIN | 321 | 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_COMMIT | 387 | 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_KEY | 844 | 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 | ||
| 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 | 128 | var Pebble = new fakes.FakePebble(false); |
| 129 | 129 | var defer = fakes.createDefer(false); |
| 130 | 130 | var failures = []; |
| 131 | + var completions = []; | |
| 131 | 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([{ 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"); }); | |
| 135 | 139 | assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), ["batch-a-1"]); |
| 136 | 140 | assert.equal(Pebble.maxInFlight, 1); |
| 137 | 141 | |
| 138 | 142 | Pebble.ack(); |
| 143 | + assert.deepEqual(completions, []); | |
| 139 | 144 | assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), ["batch-a-1"]); |
| 140 | 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 | 147 | assert.equal(defer.pending.length, 1); |
| 143 | 148 | |
| 144 | 149 | defer.runNext(); |
| 145 | 150 | Pebble.ack(); |
| 151 | + assert.deepEqual(completions, ["a"]); | |
| 146 | 152 | assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), [ |
| 147 | 153 | "batch-a-1", |
| 148 | 154 | "batch-a-2" |
| 149 | 155 | ]); |
| 150 | 156 | defer.runNext(); |
| 151 | 157 | Pebble.ack(); |
| 158 | + assert.deepEqual(completions, ["a", "b"]); | |
| 152 | 159 | defer.runNext(); |
| 153 | 160 | Pebble.ack(); |
| 154 | 161 | |
| @@ -158,6 +165,7 @@ test("callback queue yields after each ACK and drains whole batches in FIFO orde | ||
| 158 | 165 | "batch-b-1", |
| 159 | 166 | "batch-c-1" |
| 160 | 167 | ]); |
| 168 | + assert.deepEqual(completions, ["a", "b", "c"]); | |
| 161 | 169 | assert.equal(defer.pending.length, 0); |
| 162 | 170 | assert.equal(queue.isSending(), false); |
| 163 | 171 | assert.deepEqual(failures, []); |
| @@ -205,16 +213,43 @@ test("a stale failure cannot clear or fail a newer queue generation", function ( | ||
| 205 | 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 | 217 | var Pebble = new fakes.FakePebble(false); |
| 210 | 218 | var defer = fakes.createDefer(false); |
| 211 | 219 | var failures = []; |
| 220 | + var completions = []; | |
| 212 | 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 | 227 | Pebble.fail(); |
| 216 | 228 | assert.equal(Pebble.sent.length, 1); |
| 217 | 229 | assert.equal(defer.pending.length, 0); |
| 218 | 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 | 253 | assert.equal(queue.isSending(), false); |
| 254 | + assert.equal(defer.pending.length, 0); | |
| 220 | 255 | }); |
| @@ -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 @@ | ||
| 1 | 1 | #include <pebble.h> |
| 2 | 2 | |
| 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) | |
| 6 | 6 | #define XS_ARENA_TOTAL_BYTES \ |
| 7 | 7 | (XS_STACK_BYTES + XS_SLOT_BYTES + XS_CHUNK_BYTES) |
| 8 | 8 | #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 @@ | ||
| 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 | 6 | export const LIMITS = Object.freeze({ |
| 4 | 7 | idUtf8Bytes: 64, |
| 5 | 8 | labelUtf8Bytes: 96, |
| @@ -55,67 +58,34 @@ export const MESSAGE_TYPE = Object.freeze({ | ||
| 55 | 58 | 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 | 61 | // 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 | +]); | |
| 119 | 89 | |
| 120 | 90 | export const LANGUAGE = Object.freeze({ EN: "en", FR: "fr" }); |
| 121 | 91 | |
| @@ -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: 8 | 58 | 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 @@ | ||
| 1 | 1 | import { QUEUE_STATE } from "./message-queue.js"; |
| 2 | +import { LIMITS, REQUEST_TRIGGER } from "./contracts.js"; | |
| 2 | 3 | import { ProtocolReceiver, RECEIVE_RESULT, encodeRequest } from "./protocol.js"; |
| 3 | 4 | import { loadWatchConfiguration, saveWatchConfiguration } from "./storage.js"; |
| 4 | 5 | |
| @@ -15,6 +16,9 @@ export function createController(options) { | ||
| 15 | 16 | || typeof options.clock.now !== "function" |
| 16 | 17 | || !options.queue |
| 17 | 18 | || typeof options.queue.enqueue !== "function" |
| 19 | + || !options.scheduler | |
| 20 | + || typeof options.scheduler.set !== "function" | |
| 21 | + || typeof options.scheduler.clear !== "function" | |
| 18 | 22 | || !options.model |
| 19 | 23 | || typeof options.model.snapshot !== "function" |
| 20 | 24 | || !options.view |
| @@ -24,95 +28,191 @@ export function createController(options) { | ||
| 24 | 28 | |
| 25 | 29 | const clock = options.clock; |
| 26 | 30 | const queue = options.queue; |
| 31 | + const scheduler = options.scheduler; | |
| 27 | 32 | const model = options.model; |
| 28 | 33 | const storage = options.storage || null; |
| 29 | 34 | const view = options.view; |
| 30 | 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 | 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 | 47 | let started = false; |
| 35 | 48 | let closed = false; |
| 49 | + let active = true; | |
| 36 | 50 | |
| 37 | 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() { | |
| 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(); | |
| 46 | 74 | } |
| 47 | 75 | |
| 48 | - function requestFixture() { | |
| 76 | + function requestActiveFavorite(trigger) { | |
| 49 | 77 | 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"); | |
| 56 | 125 | render(); |
| 57 | 126 | return false; |
| 58 | 127 | } |
| 128 | + model.commitProtocol(snapshot, "CONFIG_COMMITTED"); | |
| 129 | + committedProtocolState = snapshot; | |
| 59 | 130 | render(); |
| 131 | + if (!appOpenRequested) requestActiveFavorite(REQUEST_TRIGGER.APP_OPEN); | |
| 60 | 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 | 146 | const controller = { |
| 64 | 147 | start() { |
| 65 | 148 | if (closed || started) return controller; |
| 66 | 149 | started = true; |
| 67 | 150 | const configuration = storage ? loadWatchConfiguration(storage) : null; |
| 68 | 151 | 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"); | |
| 76 | 155 | } |
| 156 | + render(); | |
| 77 | 157 | return controller; |
| 78 | 158 | }, |
| 79 | 159 | |
| 80 | 160 | onReadable(message) { |
| 81 | 161 | if (closed) return RECEIVE_RESULT.REJECTED; |
| 82 | 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 | 170 | 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; | |
| 101 | 178 | } |
| 179 | + | |
| 180 | + model.commitProtocol(snapshot, event); | |
| 181 | + committedProtocolState = snapshot; | |
| 182 | + render(); | |
| 102 | 183 | return received; |
| 103 | 184 | }, |
| 104 | 185 | |
| 105 | 186 | 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); | |
| 110 | 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 | 202 | 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(); | |
| 116 | 216 | }, |
| 117 | 217 | |
| 118 | 218 | redraw() { |
| @@ -122,7 +222,10 @@ export function createController(options) { | ||
| 122 | 222 | close() { |
| 123 | 223 | if (closed) return; |
| 124 | 224 | closed = true; |
| 225 | + invalidateSelectionTimer(); | |
| 226 | + invalidateConfigurationTimer(); | |
| 125 | 227 | receiver.cancelExpectedResponse(); |
| 228 | + model.cancelRequest(); | |
| 126 | 229 | receiver.discardStaging(); |
| 127 | 230 | queue.close(); |
| 128 | 231 | 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.queue | 17 | || !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.model | 22 | || !options.model |
| 19 | || typeof options.model.snapshot !== "function" | 23 | || typeof options.model.snapshot !== "function" |
| 20 | || !options.view | 24 | || !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"; | ||
| 2 | 2 | |
| 3 | 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 | 5 | export function normalizeLanguage(tag) { |
| 29 | 6 | 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; | |
| 33 | 11 | } |
| 34 | 12 | |
| 35 | 13 | 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 | + } | |
| 38 | 63 | } |
| 39 | 64 | |
| 40 | 65 | 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.FR | 7 | + const french = (tag.charCodeAt(0) === 70 || tag.charCodeAt(0) === 102) |
| 31 | - ? LANGUAGE.FR | 8 | + && (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"; | |
| 1 | 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 | 9 | import { createController } from "./controller.js"; |
| 4 | 10 | import MessageQueue from "./message-queue.js"; |
| 5 | 11 | import WatchModel from "./model.js"; |
| @@ -9,7 +15,8 @@ function screenInfo() { | ||
| 9 | 15 | return { |
| 10 | 16 | width: screen.width, |
| 11 | 17 | height: screen.height, |
| 12 | - round: screen.round === true | |
| 18 | + round: screen.round === true, | |
| 19 | + hour12: watch.hour12 === true | |
| 13 | 20 | }; |
| 14 | 21 | } |
| 15 | 22 | |
| @@ -20,6 +27,8 @@ const pendingMessages = []; | ||
| 20 | 27 | const view = createWatchView(screenInfo()); |
| 21 | 28 | const message = new Message({ |
| 22 | 29 | keys: APP_MESSAGE_KEY_MAP, |
| 30 | + input: APP_MESSAGE_INBOX_BYTES, | |
| 31 | + output: APP_MESSAGE_OUTBOX_BYTES, | |
| 23 | 32 | onReadable() { |
| 24 | 33 | const incoming = this.read(); |
| 25 | 34 | if (controller) controller.onReadable(incoming); |
| @@ -42,7 +51,8 @@ queue = new MessageQueue(message, { | ||
| 42 | 51 | }); |
| 43 | 52 | |
| 44 | 53 | controller = createController({ |
| 45 | - clock: { now: Date.now }, | |
| 54 | + clock: Date, | |
| 55 | + scheduler: Timer, | |
| 46 | 56 | queue, |
| 47 | 57 | model: new WatchModel(), |
| 48 | 58 | storage: localStorage, |
| @@ -51,10 +61,21 @@ controller = createController({ | ||
| 51 | 61 | controller.start(); |
| 52 | 62 | if (pendingChannel === "writable") queue.writable(); |
| 53 | 63 | 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 | + } | |
| 56 | 73 | }); |
| 57 | 74 | |
| 75 | +watch.addEventListener("minutechange", controller.onMinuteChange); | |
| 76 | + | |
| 77 | +watch.addEventListener("willFocus", controller.setActive); | |
| 78 | + | |
| 58 | 79 | watch.addEventListener("resize", function () { |
| 59 | 80 | view.resize(screenInfo()); |
| 60 | 81 | 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 === true | 18 | + 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 @@ | ||
| 11 | 11 | "./protocol", |
| 12 | 12 | "./message-queue", |
| 13 | 13 | "./localization", |
| 14 | + "./presentation", | |
| 14 | 15 | "./storage", |
| 15 | 16 | "./ui", |
| 16 | 17 | "./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 | +}); | |
| 2 | 18 | |
| 3 | 19 | function copyFavorite(favorite) { |
| 4 | 20 | if (!favorite) return null; |
| @@ -14,80 +30,215 @@ function copyFavorite(favorite) { | ||
| 14 | 30 | return copy; |
| 15 | 31 | } |
| 16 | 32 | |
| 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; | |
| 20 | 92 | } |
| 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 | |
| 24 | 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 | 134 | export class WatchModel { |
| 29 | 135 | constructor() { |
| 30 | 136 | this.configuration = null; |
| 137 | + this.activeFavoriteId = null; | |
| 31 | 138 | this.result = null; |
| 32 | 139 | this.error = null; |
| 33 | 140 | this.expected = null; |
| 34 | - this.appOpenPending = true; | |
| 35 | - this.sendFailed = false; | |
| 141 | + this.sendFailureFavoriteId = null; | |
| 36 | 142 | } |
| 37 | 143 | |
| 38 | 144 | commitProtocol(protocolSnapshot, event) { |
| 145 | + const previousActiveId = this.activeFavoriteId; | |
| 39 | 146 | this.configuration = protocolSnapshot.configuration; |
| 40 | 147 | this.result = protocolSnapshot.result; |
| 41 | 148 | this.error = protocolSnapshot.error; |
| 42 | 149 | |
| 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; | |
| 50 | 159 | } 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 | + } | |
| 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) { | |
| 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 | + } | |
| 60 | 198 | this.expected = { |
| 61 | 199 | requestId, |
| 62 | 200 | favoriteId: favorite.id, |
| 63 | - trigger: REQUEST_TRIGGER.APP_OPEN | |
| 201 | + trigger | |
| 64 | 202 | }; |
| 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; | |
| 67 | 211 | } |
| 68 | 212 | |
| 69 | 213 | markSendFailure() { |
| 214 | + const favorite = findFavorite(this.configuration, this.activeFavoriteId); | |
| 70 | 215 | this.expected = null; |
| 71 | - this.sendFailed = true; | |
| 216 | + this.sendFailureFavoriteId = favorite ? favorite.id : null; | |
| 72 | 217 | } |
| 73 | 218 | |
| 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 | + ); | |
| 84 | 226 | return { |
| 227 | + state: stateFor( | |
| 228 | + this.configuration, | |
| 229 | + favorite, | |
| 230 | + result, | |
| 231 | + error, | |
| 232 | + sendFailed, | |
| 233 | + nowMs | |
| 234 | + ), | |
| 85 | 235 | 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 | |
| 91 | 242 | }; |
| 92 | 243 | } |
| 93 | 244 | } |
| @@ -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.CONFIGURED | 94 | +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_OPEN | 201 | + 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.id | 221 | + const result = activeResult(this.result, favorite); |
| 77 | - ? this.result | 222 | + const error = activeError(this.error, favorite); |
| 78 | - : null; | 223 | + const sendFailed = Boolean( |
| 79 | - const error = favorite | 224 | + favorite && this.sendFailureFavoriteId === favorite.id |
| 80 | - && this.error | 225 | + ); |
| 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 } : null | 240 | + 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({ | ||
| 22 | 22 | ERROR_COMMITTED: 4 |
| 23 | 23 | }); |
| 24 | 24 | |
| 25 | -const COMMON_KEYS = Object.freeze(["SCHEMA_VERSION", "MESSAGE_TYPE"]); | |
| 26 | - | |
| 27 | -function onlyKeys(message, allowed) { | |
| 25 | +function onlyKeys(message, type) { | |
| 28 | 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 | 82 | return true; |
| 32 | 83 | } |
| @@ -44,11 +95,6 @@ function optionalUint32(message, key) { | ||
| 44 | 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 | 99 | export function decodeMessage(message) { |
| 54 | 100 | if (!(message instanceof Map) |
| @@ -59,18 +105,13 @@ export function decodeMessage(message) { | ||
| 59 | 105 | let valid = false; |
| 60 | 106 | switch (type) { |
| 61 | 107 | case MESSAGE_TYPE.REQUEST: |
| 62 | - valid = onlyKeys(message, COMMON_KEYS.concat(["REQUEST_ID", "FAVORITE_ID", "REQUEST_TRIGGER"])) | |
| 108 | + valid = onlyKeys(message, type) | |
| 63 | 109 | && string(message, "REQUEST_ID") |
| 64 | 110 | && string(message, "FAVORITE_ID") |
| 65 | 111 | && enumHasValue(REQUEST_TRIGGER, message.get("REQUEST_TRIGGER")); |
| 66 | 112 | break; |
| 67 | 113 | 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) | |
| 74 | 115 | && string(message, "REQUEST_ID") |
| 75 | 116 | && integer(message, "ITEM_COUNT") |
| 76 | 117 | && message.get("ITEM_COUNT") >= 0 |
| @@ -79,17 +120,7 @@ export function decodeMessage(message) { | ||
| 79 | 120 | && (!message.has("DISPLAY_NAME") || isWireLanguage(message.get("DISPLAY_NAME"))); |
| 80 | 121 | break; |
| 81 | 122 | 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) | |
| 93 | 124 | && string(message, "REQUEST_ID") |
| 94 | 125 | && integer(message, "ITEM_INDEX") |
| 95 | 126 | && message.get("ITEM_INDEX") >= 0 |
| @@ -105,18 +136,11 @@ export function decodeMessage(message) { | ||
| 105 | 136 | && message.get("SORT_ORDER") < LIMITS.favorites; |
| 106 | 137 | break; |
| 107 | 138 | case MESSAGE_TYPE.CONFIG_COMMIT: |
| 108 | - valid = onlyKeys(message, COMMON_KEYS.concat(["REQUEST_ID"])) | |
| 139 | + valid = onlyKeys(message, type) | |
| 109 | 140 | && string(message, "REQUEST_ID"); |
| 110 | 141 | break; |
| 111 | 142 | 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) | |
| 120 | 144 | && string(message, "REQUEST_ID") |
| 121 | 145 | && string(message, "FAVORITE_ID") |
| 122 | 146 | && integer(message, "ITEM_COUNT") |
| @@ -129,16 +153,7 @@ export function decodeMessage(message) { | ||
| 129 | 153 | && message.get("FRESHNESS") < FRESHNESS.length; |
| 130 | 154 | break; |
| 131 | 155 | 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) | |
| 142 | 157 | && string(message, "REQUEST_ID") |
| 143 | 158 | && string(message, "FAVORITE_ID") |
| 144 | 159 | && integer(message, "ITEM_INDEX") |
| @@ -158,18 +173,12 @@ export function decodeMessage(message) { | ||
| 158 | 173 | && message.get("NEXT_INTERVAL_MINUTES") <= 1440)); |
| 159 | 174 | break; |
| 160 | 175 | case MESSAGE_TYPE.RESULT_COMMIT: |
| 161 | - valid = onlyKeys(message, COMMON_KEYS.concat(["REQUEST_ID", "FAVORITE_ID"])) | |
| 176 | + valid = onlyKeys(message, type) | |
| 162 | 177 | && string(message, "REQUEST_ID") |
| 163 | 178 | && string(message, "FAVORITE_ID"); |
| 164 | 179 | break; |
| 165 | 180 | 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) | |
| 173 | 182 | && string(message, "REQUEST_ID") |
| 174 | 183 | && (!message.has("FAVORITE_ID") || string(message, "FAVORITE_ID")) |
| 175 | 184 | && integer(message, "ERROR_CODE") |
| @@ -182,7 +191,7 @@ export function decodeMessage(message) { | ||
| 182 | 191 | return null; |
| 183 | 192 | } |
| 184 | 193 | if (!valid) return null; |
| 185 | - return aliases(message); | |
| 194 | + return message; | |
| 186 | 195 | } |
| 187 | 196 | |
| 188 | 197 | export function encodeRequest(request) { |
| @@ -261,6 +270,38 @@ function copyError(error) { | ||
| 261 | 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 | 305 | export class ProtocolReceiver { |
| 265 | 306 | constructor(initialConfiguration = null) { |
| 266 | 307 | this.configuration = null; |
| @@ -268,31 +309,56 @@ export class ProtocolReceiver { | ||
| 268 | 309 | this.error = null; |
| 269 | 310 | this.configStage = null; |
| 270 | 311 | 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; | |
| 273 | 317 | if (initialConfiguration) this.restoreConfiguration(initialConfiguration); |
| 274 | 318 | } |
| 275 | 319 | |
| 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; | |
| 278 | 331 | this.result = null; |
| 279 | 332 | this.error = null; |
| 280 | 333 | this.discardStaging(); |
| 281 | 334 | this.cancelExpectedResponse(); |
| 282 | 335 | } |
| 283 | 336 | |
| 284 | - expectResponse(requestId, favoriteId) { | |
| 337 | + expectResponse(requestId, favoriteId, prefix, sequence) { | |
| 285 | 338 | 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; | |
| 290 | 353 | return true; |
| 291 | 354 | } |
| 292 | 355 | |
| 293 | 356 | 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; | |
| 296 | 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 | 386 | receive(message) { |
| 313 | 387 | const decoded = decodeMessage(message); |
| 314 | 388 | if (!decoded) { |
| @@ -316,14 +390,14 @@ export class ProtocolReceiver { | ||
| 316 | 390 | return RECEIVE_RESULT.REJECTED; |
| 317 | 391 | } |
| 318 | 392 | |
| 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") || ""; | |
| 321 | 395 | if (type === MESSAGE_TYPE.CONFIG_BEGIN) { |
| 322 | 396 | this.configStage = { |
| 323 | 397 | 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, | |
| 327 | 401 | favorites: [] |
| 328 | 402 | }; |
| 329 | 403 | return RECEIVE_RESULT.STAGED; |
| @@ -334,26 +408,71 @@ export class ProtocolReceiver { | ||
| 334 | 408 | let duplicate = false; |
| 335 | 409 | if (stage) { |
| 336 | 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 | 414 | if (!stage |
| 341 | 415 | || stage.requestId !== requestId |
| 342 | - || decoded.ITEM_INDEX !== stage.favorites.length | |
| 416 | + || decoded.get("ITEM_INDEX") !== stage.favorites.length | |
| 343 | 417 | || stage.favorites.length >= stage.count |
| 344 | 418 | || duplicate) { |
| 345 | 419 | this.configStage = null; |
| 346 | 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 | 435 | 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") | |
| 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 | 476 | stage.favorites.push(favorite); |
| 358 | 477 | return RECEIVE_RESULT.STAGED; |
| 359 | 478 | } |
| @@ -366,12 +485,21 @@ export class ProtocolReceiver { | ||
| 366 | 485 | this.configStage = null; |
| 367 | 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 | 497 | this.configuration = { |
| 370 | 498 | keyStatus: stage.keyStatus, |
| 371 | 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 | 503 | this.error = null; |
| 376 | 504 | this.configStage = null; |
| 377 | 505 | this.cancelExpectedResponse(); |
| @@ -379,37 +507,43 @@ export class ProtocolReceiver { | ||
| 379 | 507 | } |
| 380 | 508 | |
| 381 | 509 | 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) { | |
| 387 | 520 | return RECEIVE_RESULT.REJECTED; |
| 388 | 521 | } |
| 389 | - this.expected = { | |
| 390 | - requestId, | |
| 391 | - favoriteId: decoded.FAVORITE_ID, | |
| 392 | - refresh: true | |
| 393 | - }; | |
| 394 | - this.refreshCandidate = null; | |
| 522 | + this.refreshSeq = sequence; | |
| 395 | 523 | this.resultStage = null; |
| 396 | 524 | return RECEIVE_RESULT.STAGED; |
| 397 | 525 | } |
| 398 | 526 | |
| 399 | 527 | 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)) { | |
| 403 | 537 | this.resultStage = null; |
| 404 | 538 | return RECEIVE_RESULT.REJECTED; |
| 405 | 539 | } |
| 406 | 540 | this.resultStage = { |
| 407 | 541 | 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")], | |
| 413 | 547 | departures: [] |
| 414 | 548 | }; |
| 415 | 549 | return RECEIVE_RESULT.STAGED; |
| @@ -418,24 +552,21 @@ export class ProtocolReceiver { | ||
| 418 | 552 | if (type === MESSAGE_TYPE.DEPARTURE) { |
| 419 | 553 | const stage = this.resultStage; |
| 420 | 554 | if (!stage |
| 421 | - || !this.expected | |
| 422 | 555 | || 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 | |
| 427 | 558 | || stage.departures.length >= stage.count) { |
| 428 | 559 | this.resultStage = null; |
| 429 | 560 | return RECEIVE_RESULT.REJECTED; |
| 430 | 561 | } |
| 431 | 562 | 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")] | |
| 435 | 566 | }; |
| 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"); | |
| 439 | 570 | } |
| 440 | 571 | stage.departures.push(departure); |
| 441 | 572 | return RECEIVE_RESULT.STAGED; |
| @@ -443,45 +574,55 @@ export class ProtocolReceiver { | ||
| 443 | 574 | |
| 444 | 575 | if (type === MESSAGE_TYPE.RESULT_COMMIT) { |
| 445 | 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 | 584 | if (!stage |
| 447 | - || !this.expected | |
| 585 | + || (!refreshing | |
| 586 | + && !(sequence > this.committedSeq && sequence <= this.latestSeq)) | |
| 448 | 587 | || 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 | |
| 452 | 589 | || stage.departures.length !== stage.count) { |
| 453 | 590 | this.resultStage = null; |
| 454 | 591 | return RECEIVE_RESULT.REJECTED; |
| 455 | 592 | } |
| 456 | - const refresh = this.expected.refresh; | |
| 457 | - this.result = copyResult(stage); | |
| 593 | + this.result = stage; | |
| 458 | 594 | this.error = null; |
| 459 | - this.expected = null; | |
| 595 | + if (sequence > this.committedSeq) this.committedSeq = sequence; | |
| 460 | 596 | this.resultStage = null; |
| 461 | - this.refreshCandidate = refresh | |
| 462 | - ? null | |
| 463 | - : { requestId, favoriteId: decoded.FAVORITE_ID }; | |
| 597 | + this.refreshSeq = refreshing ? 0 : -sequence; | |
| 464 | 598 | return RECEIVE_RESULT.RESULT_COMMITTED; |
| 465 | 599 | } |
| 466 | 600 | |
| 467 | 601 | 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)) { | |
| 472 | 611 | this.resultStage = null; |
| 473 | 612 | return RECEIVE_RESULT.REJECTED; |
| 474 | 613 | } |
| 475 | 614 | this.error = { |
| 476 | 615 | 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") | |
| 479 | 618 | }; |
| 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"); | |
| 483 | 622 | } |
| 484 | - this.cancelExpectedResponse(); | |
| 623 | + if (sequence > this.committedSeq) this.committedSeq = sequence; | |
| 624 | + this.refreshSeq = 0; | |
| 625 | + this.resultStage = null; | |
| 485 | 626 | return RECEIVE_RESULT.ERROR_COMMITTED; |
| 486 | 627 | } |
| 487 | 628 | |
| @@ -22,11 +22,62 @@ export const RECEIVE_RESULT = Object.freeze({ | |||
| 22 | ERROR_COMMITTED: 4 | 22 | 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") >= 0 | 117 | && 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") >= 0 | 126 | && 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 (!stage | 414 | if (!stage |
| 341 | || stage.requestId !== requestId | 415 | || stage.requestId !== requestId |
| 342 | - || decoded.ITEM_INDEX !== stage.favorites.length | 416 | + || decoded.get("ITEM_INDEX") !== stage.favorites.length |
| 343 | || stage.favorites.length >= stage.count | 417 | || 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_ORDER | 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") | ||
| 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.expected | 511 | + const sequence = favoriteId === this.favoriteId |
| 384 | - || !candidate | 512 | + ? requestSequence(this.prefix, requestId) |
| 385 | - || candidate.requestId !== requestId | 513 | + : 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.expected | 528 | + const favoriteId = decoded.get("FAVORITE_ID"); |
| 401 | - || this.expected.requestId !== requestId | 529 | + 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 (!stage | 554 | if (!stage |
| 421 | - || !this.expected | ||
| 422 | || stage.requestId !== requestId | 555 | || stage.requestId !== requestId |
| 423 | - || stage.favoriteId !== decoded.FAVORITE_ID | 556 | + || stage.favoriteId !== decoded.get("FAVORITE_ID") |
| 424 | - || this.expected.requestId !== requestId | 557 | + || 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 (!stage | 584 | if (!stage |
| 447 | - || !this.expected | 585 | + || (!refreshing |
| 586 | + && !(sequence > this.committedSeq && sequence <= this.latestSeq)) | ||
| 448 | || stage.requestId !== requestId | 587 | || stage.requestId !== requestId |
| 449 | - || stage.favoriteId !== decoded.FAVORITE_ID | 588 | + || 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 = refresh | 597 | + 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.expected | 602 | + const favoriteId = decoded.get("FAVORITE_ID"); |
| 469 | - || this.expected.requestId !== requestId | 603 | + const sequence = (favoriteId === undefined || favoriteId === this.favoriteId) |
| 470 | - || (decoded.FAVORITE_ID !== undefined | 604 | + ? 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_AT | 617 | + 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 { | ||
| 3 | 3 | LIMITS, |
| 4 | 4 | SCHEMA_VERSION, |
| 5 | 5 | boundedString, |
| 6 | - enumHasValue, | |
| 7 | 6 | isWireLanguage, |
| 8 | 7 | utf8Bytes |
| 9 | 8 | } from "./contracts.js"; |
| @@ -13,31 +12,8 @@ export const WATCH_CONFIGURATION_MAX_BYTES = 8192; | ||
| 13 | 12 | |
| 14 | 13 | const STORAGE_FORMAT = "LFW1"; |
| 15 | 14 | 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); | |
| 41 | 17 | } |
| 42 | 18 | |
| 43 | 19 | function validInteger(value) { |
| @@ -45,8 +21,28 @@ function validInteger(value) { | ||
| 45 | 21 | } |
| 46 | 22 | |
| 47 | 23 | 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) | |
| 50 | 46 | && boundedString(favorite.serviceId, LIMITS.idUtf8Bytes) |
| 51 | 47 | && boundedString(favorite.stopLabel, LIMITS.labelUtf8Bytes) |
| 52 | 48 | && boundedString(favorite.lineLabel, LIMITS.labelUtf8Bytes) |
| @@ -60,43 +56,37 @@ function validFavorite(favorite) { | ||
| 60 | 56 | |
| 61 | 57 | function validFavorites(favorites) { |
| 62 | 58 | if (!Array.isArray(favorites) || favorites.length > LIMITS.favorites) return false; |
| 63 | - const ids = []; | |
| 64 | 59 | for (let index = 0; index < favorites.length; index += 1) { |
| 65 | 60 | 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 | + } | |
| 68 | 65 | } |
| 69 | 66 | return true; |
| 70 | 67 | } |
| 71 | 68 | |
| 72 | 69 | 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) | |
| 75 | 86 | && isWireLanguage(configuration.language) |
| 76 | 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 | 90 | function appendToken(parts, value) { |
| 101 | 91 | parts.push(String(value), STORAGE_SEPARATOR); |
| 102 | 92 | } |
| @@ -110,6 +100,48 @@ function appendString(parts, value) { | ||
| 110 | 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 | 145 | export function serializeWatchConfiguration(configuration) { |
| 114 | 146 | if (!validConfiguration(configuration)) return null; |
| 115 | 147 | const parts = []; |
| @@ -201,7 +233,7 @@ export function deserializeWatchConfiguration(serialized) { | ||
| 201 | 233 | } |
| 202 | 234 | if (!state.valid || state.offset !== serialized.length |
| 203 | 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 | 260 | if (!canWrite(storage)) return false; |
| 229 | 261 | |
| 230 | 262 | let previous = null; |
| 263 | + let serialized = null; | |
| 231 | 264 | let writeAttempted = false; |
| 232 | 265 | try { |
| 233 | - const serialized = serializeWatchConfiguration(configuration); | |
| 266 | + serialized = serializeWatchConfiguration(configuration); | |
| 234 | 267 | if (serialized === null) return false; |
| 235 | 268 | previous = storage.getItem(WATCH_CONFIGURATION_KEY); |
| 236 | 269 | writeAttempted = true; |
| 237 | 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 | 275 | } catch (_) { |
| 240 | 276 | // Restore the prior bytes below when the adapter is still writable. |
| 241 | 277 | } |
| @@ -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 | utf8Bytes | 7 | 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.length | 234 | 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 @@ | ||
| 1 | 1 | 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; | |
| 22 | 34 | |
| 23 | 35 | function geometry(screenInfo) { |
| 24 | 36 | const width = Math.max(1, Number(screenInfo.width) || 1); |
| 25 | 37 | const height = Math.max(1, Number(screenInfo.height) || 1); |
| 26 | 38 | const round = screenInfo.round === true; |
| 27 | - const horizontal = round | |
| 39 | + const insetX = round | |
| 28 | 40 | ? Math.max(26, Math.floor(width * 0.13)) |
| 29 | 41 | : Math.max(8, Math.floor(width * 0.04)); |
| 30 | - const vertical = round | |
| 42 | + const insetY = round | |
| 31 | 43 | ? Math.max(18, Math.floor(height * 0.08)) |
| 32 | 44 | : 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; | |
| 35 | 49 | 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 | |
| 55 | 60 | }; |
| 56 | 61 | } |
| 57 | 62 | |
| 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); | |
| 60 | 67 | } |
| 61 | 68 | |
| 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; | |
| 65 | 88 | } |
| 66 | 89 | |
| 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; | |
| 71 | 106 | } |
| 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; | |
| 76 | 162 | } |
| 77 | - return lines.join("\n"); | |
| 78 | 163 | } |
| 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; | |
| 83 | 165 | } |
| 84 | 166 | |
| 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 | + } | |
| 89 | 296 | } |
| 90 | 297 | |
| 91 | 298 | export function createWatchView(screenInfo) { |
| 92 | 299 | 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 | |
| 107 | 306 | }); |
| 307 | + port.behavior.layout = layout; | |
| 108 | 308 | const application = new Application(null, { |
| 109 | - skin: BACKGROUND_SKIN, | |
| 309 | + clip: true, | |
| 110 | 310 | touchCount: 0, |
| 111 | - displayListLength: 2048, | |
| 112 | - contents: [header, body, footer] | |
| 311 | + contents: [port] | |
| 113 | 312 | }); |
| 114 | 313 | |
| 115 | 314 | return { |
| 116 | 315 | application, |
| 117 | 316 | 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(); | |
| 121 | 320 | }, |
| 122 | 321 | resize(nextScreenInfo) { |
| 123 | 322 | 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; | |
| 127 | 330 | }, |
| 128 | 331 | close() { |
| 332 | + port.behavior.snapshot = null; | |
| 333 | + contentStyle = null; | |
| 334 | + footerStyle = null; | |
| 129 | 335 | application.empty(); |
| 130 | 336 | } |
| 131 | 337 | }; |
| @@ -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: 1 | 9 | + 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: 2 | 15 | + }); |
| 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 = round | 39 | + 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 = round | 42 | + 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: headerHeight | 55 | + 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"; | ||
| 2 | 2 | import assert from "node:assert/strict"; |
| 3 | 3 | import { |
| 4 | 4 | KEY_STATUS, |
| 5 | + LIMITS, | |
| 5 | 6 | MESSAGE_TYPE, |
| 7 | + REQUEST_TRIGGER, | |
| 6 | 8 | SCHEMA_VERSION |
| 7 | 9 | } from "../src/embeddedjs/contracts.js"; |
| 10 | +import { QUEUE_STATE } from "../src/embeddedjs/message-queue.js"; | |
| 8 | 11 | import { createController } from "../src/embeddedjs/controller.js"; |
| 9 | 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 | 14 | import { |
| 12 | 15 | WATCH_CONFIGURATION_KEY, |
| 13 | 16 | deserializeWatchConfiguration, |
| @@ -38,6 +41,14 @@ const NEW_FAVORITES = [{ | ||
| 38 | 41 | lineLabel: "Métro 9", |
| 39 | 42 | destinationLabel: "Pont de Sèvres", |
| 40 | 43 | 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 | 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 | 135 | class Queue { |
| 68 | 136 | constructor() { |
| 137 | + this.accept = true; | |
| 138 | + this.attempts = []; | |
| 69 | 139 | this.messages = []; |
| 140 | + this.closed = 0; | |
| 70 | 141 | } |
| 71 | 142 | |
| 72 | 143 | enqueue(message) { |
| 144 | + this.attempts.push(message); | |
| 145 | + if (!this.accept) return false; | |
| 73 | 146 | this.messages.push(message); |
| 74 | 147 | return true; |
| 75 | 148 | } |
| 76 | 149 | |
| 77 | - close() {} | |
| 150 | + close() { | |
| 151 | + this.closed += 1; | |
| 152 | + } | |
| 78 | 153 | } |
| 79 | 154 | |
| 80 | 155 | class View { |
| 81 | 156 | constructor() { |
| 82 | 157 | this.renders = []; |
| 158 | + this.closed = 0; | |
| 83 | 159 | } |
| 84 | 160 | |
| 85 | 161 | render(snapshot) { |
| 86 | 162 | this.renders.push(snapshot); |
| 87 | 163 | } |
| 88 | 164 | |
| 89 | - close() {} | |
| 165 | + close() { | |
| 166 | + this.closed += 1; | |
| 167 | + } | |
| 90 | 168 | } |
| 91 | 169 | |
| 92 | 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 | 192 | return [ |
| 110 | 193 | wire(MESSAGE_TYPE.CONFIG_BEGIN, [ |
| 111 | 194 | ["REQUEST_ID", id], |
| 112 | 195 | ["ITEM_COUNT", favorites.length], |
| 113 | - ["KEY_STATUS", KEY_STATUS.CONFIGURED], | |
| 196 | + ["KEY_STATUS", keyStatus], | |
| 114 | 197 | ["DISPLAY_NAME", language] |
| 115 | 198 | ]), |
| 116 | 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 | 249 | const queue = new Queue(); |
| 133 | 250 | const view = new View(); |
| 134 | 251 | const controller = createController({ |
| 135 | - clock: { now: () => 1_788_000_000_000 }, | |
| 252 | + clock, | |
| 136 | 253 | queue, |
| 254 | + scheduler, | |
| 137 | 255 | model: new WatchModel(), |
| 138 | 256 | storage, |
| 139 | 257 | view |
| 140 | 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 | 277 | 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); | |
| 155 | 305 | assert.deepEqual(deserializeWatchConfiguration(storage.value), { |
| 156 | 306 | keyStatus: KEY_STATUS.CONFIGURED, |
| 157 | 307 | language: "en", |
| @@ -159,11 +309,34 @@ test("stored configuration renders offline and refreshes after phone synchroniza | ||
| 159 | 309 | }); |
| 160 | 310 | |
| 161 | 311 | 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 }); | |
| 164 | 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 | 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 | 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 | 345 | const previous = stored(); |
| 173 | 346 | const storage = new StringStorage(previous); |
| 174 | 347 | const current = harness(storage); |
| 175 | - messages.slice(0, delivered).forEach(current.controller.onReadable); | |
| 348 | + deliver(current.controller, messages.slice(0, delivered)); | |
| 176 | 349 | assert.equal(storage.value, previous, "delivery prefix " + delivered); |
| 177 | 350 | |
| 178 | 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 | 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 | 358 | const previous = stored(); |
| 186 | 359 | const storage = new StringStorage(previous); |
| 187 | 360 | const target = harness(storage); |
| @@ -191,9 +364,329 @@ test("unsupported input and failed persistence roll back to the prior configurat | ||
| 191 | 364 | assert.equal(storage.value, previous); |
| 192 | 365 | |
| 193 | 366 | 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); | |
| 196 | 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 | 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 | }); |
| @@ -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_VERSION | 8 | 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: 1 | 43 | 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 | view | 257 | 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"; | ||
| 2 | 2 | import assert from "node:assert/strict"; |
| 3 | 3 | import { createRequire } from "node:module"; |
| 4 | 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 | 9 | const require = createRequire(import.meta.url); |
| 8 | 10 | const companion = require("../../companion/src/index.js"); |
| 9 | -const fixture = require("../../../fixtures/departures/foundation.json"); | |
| 10 | 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 | 19 | class WatchQueue { |
| 13 | 20 | constructor() { |
| @@ -24,11 +31,15 @@ class WatchQueue { | ||
| 24 | 31 | |
| 25 | 32 | class WatchView { |
| 26 | 33 | constructor() { |
| 27 | - this.snapshots = []; | |
| 34 | + this.frames = []; | |
| 35 | + this.screenInfo = { round: false, hour12: false }; | |
| 28 | 36 | } |
| 29 | 37 | |
| 30 | 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 | 45 | close() {} |
| @@ -38,65 +49,490 @@ function aliasMap(dictionary) { | ||
| 38 | 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", () => { | |
| 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) { | |
| 43 | 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 | 91 | const xhr = fakes.createXHRFactory(); |
| 46 | - companion.createCompanion({ | |
| 92 | + const phone = companion.createCompanion({ | |
| 47 | 93 | Pebble, |
| 48 | 94 | storage, |
| 49 | 95 | 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" | |
| 55 | 101 | }); |
| 56 | - | |
| 57 | 102 | const watchQueue = new WatchQueue(); |
| 58 | 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 | 112 | const controller = createController({ |
| 60 | - clock: { now: () => phoneClock.now() }, | |
| 113 | + clock, | |
| 61 | 114 | queue: watchQueue, |
| 62 | - view: watchView, | |
| 63 | - model: new WatchModel() | |
| 115 | + scheduler, | |
| 116 | + model: new WatchModel(), | |
| 117 | + view: watchView | |
| 64 | 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"); | |
| 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 | |
| 72 | 198 | ]); |
| 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 | |
| 90 | 268 | ]); |
| 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 | + ); | |
| 102 | 538 | }); |
| @@ -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 | - fixture | 100 | + 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_COMMIT | 196 | + 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_COMMIT | 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 | ||
| 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 @@ | ||
| 1 | 1 | import test from "node:test"; |
| 2 | 2 | import assert from "node:assert/strict"; |
| 3 | 3 | import { |
| 4 | - COPY, | |
| 5 | 4 | copy, |
| 6 | 5 | formatMinutes, |
| 7 | 6 | normalizeLanguage |
| 8 | 7 | } from "../src/embeddedjs/localization.js"; |
| 9 | 8 | |
| 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 | + } | |
| 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 | 65 | ["fr", "fr-FR", "fr_FR", "fr-CA", "FR_fr"].forEach((tag) => { |
| 22 | 66 | assert.equal(normalizeLanguage(tag), "fr"); |
| 67 | + assert.equal(copy(tag, "loading"), "Chargement des départs…"); | |
| 23 | 68 | }); |
| 24 | 69 | [undefined, null, "", "en", "en-US", "de-DE", "french"].forEach((tag) => { |
| 25 | 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…"); | |
| 28 | - assert.equal(copy("de_DE", "waiting"), "Waiting for recorded departures…"); | |
| 73 | + assert.equal(copy("fr", "not-a-copy-key"), ""); | |
| 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 | 77 | assert.equal(formatMinutes("en", -1), "Departed"); |
| 78 | + assert.equal(formatMinutes("fr", -1), "Parti"); | |
| 79 | + assert.equal(formatMinutes("en", 0), "Now"); | |
| 33 | 80 | assert.equal(formatMinutes("fr", 0), "À quai"); |
| 34 | 81 | 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"), ""); | |
| 43 | 84 | }); |
| @@ -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 | normalizeLanguage | 6 | 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"; | ||
| 4 | 4 | import { existsSync, readFileSync, readdirSync } from "node:fs"; |
| 5 | 5 | import { fileURLToPath } from "node:url"; |
| 6 | 6 | import { |
| 7 | - APP_MESSAGE_KEY as WATCH_MESSAGE_KEY, | |
| 7 | + APP_MESSAGE_INBOX_BYTES as WATCH_MESSAGE_INBOX_BYTES, | |
| 8 | 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 | 10 | LANGUAGE as WATCH_LANGUAGE |
| 11 | 11 | } from "../src/embeddedjs/contracts.js"; |
| 12 | 12 | import { |
| @@ -33,6 +33,7 @@ const expectedModules = [ | ||
| 33 | 33 | "./protocol", |
| 34 | 34 | "./message-queue", |
| 35 | 35 | "./localization", |
| 36 | + "./presentation", | |
| 36 | 37 | "./storage", |
| 37 | 38 | "./ui", |
| 38 | 39 | "./contracts" |
| @@ -45,10 +46,10 @@ function moduleSource(name) { | ||
| 45 | 46 | function xsArenaBytes(field) { |
| 46 | 47 | const constant = `XS_${field.toUpperCase()}_BYTES`; |
| 47 | 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 | - return Number(match[1]) * 1024; | |
| 51 | + assert.notEqual(match, null, `${constant} must be an explicit allocation`); | |
| 52 | + return Number(match[1]) * Number(match[2]); | |
| 52 | 53 | } |
| 53 | 54 | |
| 54 | 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 | 71 | const arenaValues = Object.values(arenas); |
| 71 | 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 | 74 | assert.equal(arenaValues.every((bytes) => bytes > 0), true); |
| 79 | 75 | assert.equal(arenaTotal, 56 * 1024); |
| 80 | 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 | 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 | 115 | const source = moduleSource("./main"); |
| 120 | 116 | const expectedEntries = CANONICAL_MESSAGE_KEY_ORDER.map((alias) => [ |
| 121 | 117 | alias, |
| @@ -125,16 +121,23 @@ test("Alloy Message receives an explicit frozen-ID key Map", () => { | ||
| 125 | 121 | assert.equal(WATCH_MESSAGE_KEY_MAP instanceof Map, true); |
| 126 | 122 | assert.equal(Array.isArray(WATCH_MESSAGE_KEY_MAP), false); |
| 127 | 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 | 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 | 129 | assert.doesNotMatch(source, /keys:\s*APP_MESSAGE_KEY_ORDER/u); |
| 130 | 130 | assert.doesNotMatch(source, /\bformat\s*:/u); |
| 131 | 131 | }); |
| 132 | 132 | |
| 133 | 133 | test("package, canonical, companion, and embedded numeric maps are identical", () => { |
| 134 | 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 | 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 | 141 | assert.deepEqual(companionContracts.APP_MESSAGE_KEY_ORDER, CANONICAL_MESSAGE_KEY_ORDER); |
| 139 | 142 | assert.deepEqual(WATCH_LANGUAGE, CANONICAL_LANGUAGE); |
| 140 | 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 | }); |
| @@ -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_LANGUAGE | 10 | 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 @@ | ||
| 1 | 1 | import test from "node:test"; |
| 2 | 2 | import assert from "node:assert/strict"; |
| 3 | 3 | import { |
| 4 | + DEPARTURE_STATUS, | |
| 5 | + ERROR_CODE, | |
| 6 | + FRESHNESS, | |
| 4 | 7 | KEY_STATUS, |
| 5 | - MESSAGE_TYPE, | |
| 6 | - REQUEST_TRIGGER, | |
| 7 | - SCHEMA_VERSION | |
| 8 | + REQUEST_TRIGGER | |
| 8 | 9 | } 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; | |
| 29 | 26 | } |
| 30 | 27 | |
| 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 | + }; | |
| 52 | 39 | } |
| 53 | 40 | |
| 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 | + }; | |
| 77 | 48 | } |
| 78 | 49 | |
| 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 | +} | |
| 84 | 60 | |
| 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 | +} | |
| 89 | 70 | |
| 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; | |
| 93 | 80 | } |
| 94 | 81 | |
| 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); | |
| 98 | 293 | } |
| 99 | 294 | |
| 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(); | |
| 102 | 309 | } |
| 103 | 310 | |
| 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 | +}); | |
| 106 | 315 | |
| 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); | |
| 118 | 324 | |
| 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"); | |
| 152 | 342 | }); |
| 153 | 343 | |
| 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); | |
| 177 | 397 | }); |
| @@ -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: 0 | 21 | + 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 | - view | 323 | + 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: null | 334 | + 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"; | ||
| 2 | 2 | import assert from "node:assert/strict"; |
| 3 | 3 | import { |
| 4 | 4 | KEY_STATUS, |
| 5 | + LIMITS, | |
| 5 | 6 | MESSAGE_TYPE, |
| 6 | 7 | REQUEST_TRIGGER, |
| 7 | 8 | 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 | 31 | return message(MESSAGE_TYPE.CONFIG_BEGIN, [ |
| 26 | 32 | ["REQUEST_ID", id], |
| 27 | 33 | ["ITEM_COUNT", count], |
| 28 | - ["KEY_STATUS", KEY_STATUS.CONFIGURED], | |
| 34 | + ["KEY_STATUS", keyStatus], | |
| 29 | 35 | ["DISPLAY_NAME", language] |
| 30 | 36 | ]); |
| 31 | 37 | } |
| @@ -48,7 +54,7 @@ function configCommit(id = "config-1") { | ||
| 48 | 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 | 58 | return message(MESSAGE_TYPE.RESULT_BEGIN, [ |
| 53 | 59 | ["REQUEST_ID", requestId], |
| 54 | 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 | 69 | return message(MESSAGE_TYPE.DEPARTURE, [ |
| 64 | 70 | ["REQUEST_ID", requestId], |
| 65 | 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 | 82 | return message(MESSAGE_TYPE.RESULT_COMMIT, [ |
| 77 | 83 | ["REQUEST_ID", requestId], |
| 78 | 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 | 89 | return message(MESSAGE_TYPE.ERROR, [ |
| 84 | 90 | ["REQUEST_ID", requestId], |
| 85 | 91 | ["FAVORITE_ID", favoriteId], |
| @@ -96,7 +102,7 @@ function commitConfiguration(receiver) { | ||
| 96 | 102 | } |
| 97 | 103 | |
| 98 | 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 | 106 | assert.equal(receiver.receive(resultBegin()), RECEIVE_RESULT.STAGED); |
| 101 | 107 | assert.equal(receiver.receive(departure()), RECEIVE_RESULT.STAGED); |
| 102 | 108 | assert.equal(receiver.receive(resultCommit()), RECEIVE_RESULT.RESULT_COMMITTED); |
| @@ -104,7 +110,7 @@ function commitResult(receiver) { | ||
| 104 | 110 | |
| 105 | 111 | test("decoder validates all eight message types and request encoder returns aliases", () => { |
| 106 | 112 | const request = encodeRequest({ |
| 107 | - requestId: "request-1", | |
| 113 | + requestId: "consult-test-1", | |
| 108 | 114 | favoriteId: "home", |
| 109 | 115 | 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 | 135 | assert.equal([...request.keys()].some((key) => /^\d+$/u.test(key)), false); |
| 130 | 136 | assert.throws(() => encodeRequest({ |
| 131 | - requestId: "request-1", | |
| 137 | + requestId: "consult-test-1", | |
| 132 | 138 | favoriteId: "home", |
| 133 | 139 | trigger: 99 |
| 134 | 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 | 196 | test("out-of-order, duplicate, and incomplete configuration cannot replace committed state", () => { |
| 179 | 197 | const receiver = new ProtocolReceiver(); |
| 180 | 198 | commitConfiguration(receiver); |
| @@ -195,14 +213,14 @@ test("out-of-order, duplicate, and incomplete configuration cannot replace commi | ||
| 195 | 213 | test("result stages atomically only for the expected request and favorite", () => { |
| 196 | 214 | const receiver = new ProtocolReceiver(); |
| 197 | 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 | 217 | assert.equal(receiver.receive(resultBegin()), RECEIVE_RESULT.STAGED); |
| 200 | 218 | assert.equal(receiver.snapshot().result, null); |
| 201 | 219 | assert.equal(receiver.receive(departure()), RECEIVE_RESULT.STAGED); |
| 202 | 220 | assert.equal(receiver.snapshot().result, null); |
| 203 | 221 | assert.equal(receiver.receive(resultCommit()), RECEIVE_RESULT.RESULT_COMMITTED); |
| 204 | 222 | assert.deepEqual(receiver.snapshot().result, { |
| 205 | - requestId: "request-1", | |
| 223 | + requestId: "consult-test-1", | |
| 206 | 224 | favoriteId: "home", |
| 207 | 225 | fetchedAt: 1_788_000_000, |
| 208 | 226 | sourceUpdatedAt: 1_787_999_990, |
| @@ -220,14 +238,14 @@ test("result stages atomically only for the expected request and favorite", () = | ||
| 220 | 238 | test("matching request handshake permits one cached result replacement", () => { |
| 221 | 239 | const receiver = new ProtocolReceiver(); |
| 222 | 240 | const request = encodeRequest({ |
| 223 | - requestId: "request-1", | |
| 241 | + requestId: "consult-test-1", | |
| 224 | 242 | favoriteId: "home", |
| 225 | 243 | trigger: REQUEST_TRIGGER.APP_OPEN |
| 226 | 244 | }); |
| 227 | 245 | const freshBegin = resultBegin(); |
| 228 | 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 | 249 | assert.equal(receiver.receive(resultBegin()), RECEIVE_RESULT.STAGED); |
| 232 | 250 | assert.equal(receiver.receive(departure()), RECEIVE_RESULT.STAGED); |
| 233 | 251 | assert.equal(receiver.receive(resultCommit()), RECEIVE_RESULT.RESULT_COMMITTED); |
| @@ -241,20 +259,44 @@ test("matching request handshake permits one cached result replacement", () => { | ||
| 241 | 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 | 286 | test("late items, count mismatches, unsupported schema, and mismatched errors preserve state", () => { |
| 245 | 287 | const receiver = new ProtocolReceiver(); |
| 246 | 288 | commitConfiguration(receiver); |
| 247 | 289 | commitResult(receiver); |
| 248 | 290 | const committed = receiver.snapshot(); |
| 249 | 291 | |
| 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); | |
| 255 | 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 | 300 | const unsupported = configBegin("config-new", 0, "en"); |
| 259 | 301 | unsupported.set("SCHEMA_VERSION", 99); |
| 260 | 302 | assert.equal(receiver.receive(unsupported), RECEIVE_RESULT.REJECTED); |
| @@ -265,11 +307,11 @@ test("matching error commits without destroying the prior complete result", () = | ||
| 265 | 307 | const receiver = new ProtocolReceiver(); |
| 266 | 308 | commitResult(receiver); |
| 267 | 309 | 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); | |
| 270 | 312 | assert.deepEqual(receiver.snapshot().result, result); |
| 271 | 313 | assert.deepEqual(receiver.snapshot().error, { |
| 272 | - requestId: "request-2", | |
| 314 | + requestId: "consult-test-2", | |
| 273 | 315 | favoriteId: "home", |
| 274 | 316 | code: "SOURCE_UNAVAILABLE", |
| 275 | 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 | 348 | test("receiver seeds, restores, and idempotently repeats complete configuration", () => { |
| 281 | 349 | const initial = { |
| 282 | 350 | keyStatus: KEY_STATUS.CONFIGURED, |
| @@ -305,3 +373,65 @@ test("receiver seeds, restores, and idempotently repeats complete configuration" | ||
| 305 | 373 | assert.equal(receiver.receive(configCommit("interrupted")), RECEIVE_RESULT.REJECTED); |
| 306 | 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 | +}); | |
| @@ -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_VERSION | 8 | 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_SELECT | 115 | 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: 99 | 139 | 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_OPEN | 243 | 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 @@ | ||
| 1 | 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 | 3 | import { basename, join } from "node:path"; |
| 4 | 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) { | |
| 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`; | |
| 37 | 37 | } |
| 38 | 38 | |
| 39 | 39 | function run(command, args, cwd = root) { |
| @@ -48,7 +48,6 @@ function run(command, args, cwd = root) { | ||
| 48 | 48 | |
| 49 | 49 | function build() { |
| 50 | 50 | const configuration = readBuildConfiguration(process.env); |
| 51 | - const fixture = JSON.parse(readFileSync(join(root, "fixtures/departures/foundation.json"), "utf8")); | |
| 52 | 51 | |
| 53 | 52 | run(process.execPath, ["scripts/test.mjs"]); |
| 54 | 53 | run(process.execPath, ["scripts/measure-radio.mjs"]); |
| @@ -59,7 +58,7 @@ function build() { | ||
| 59 | 58 | cpSync(join(companionSource, source), join(pkjsTarget, basename(source))); |
| 60 | 59 | } |
| 61 | 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 | 63 | const watch = join(root, "packages/watch"); |
| 65 | 64 | 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); | ||
| 5 | 5 | const codec = require("../packages/companion/src/codec.js"); |
| 6 | 6 | const contracts = require("../packages/companion/src/contracts.js"); |
| 7 | 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 | 12 | import { |
| 9 | 13 | serializeWatchConfiguration, |
| 10 | 14 | WATCH_CONFIGURATION_MAX_BYTES, |
| @@ -24,6 +28,21 @@ function dictionarySize(dictionary) { | ||
| 24 | 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 | 46 | function maximumEncodedString(byteLimit, suffix = "") { |
| 28 | 47 | return "\\".repeat(byteLimit - Buffer.byteLength(suffix, "utf8")) + suffix; |
| 29 | 48 | } |
| @@ -33,22 +52,8 @@ function longestLabel(values) { | ||
| 33 | 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 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. | |
| 52 | 57 | const appMessageEightKilobyteBytes = 8200; |
| 53 | 58 | |
| 54 | 59 | // Maximal frozen DepartureResult bounds from the contracts validators |
| @@ -68,10 +73,10 @@ const maximumDeparture = { | ||
| 68 | 73 | nextIntervalMinutes: 1440, |
| 69 | 74 | }; |
| 70 | 75 | |
| 71 | -function maximumResult(favoriteId) { | |
| 76 | +function maximumResult(requestId, favoriteId) { | |
| 72 | 77 | return { |
| 73 | 78 | schemaVersion: contracts.SCHEMA_VERSION, |
| 74 | - requestId: maximumEncodedString(contracts.LIMITS.idUtf8Bytes), | |
| 79 | + requestId, | |
| 75 | 80 | favoriteId, |
| 76 | 81 | fetchedAt: UINT32_MAX, |
| 77 | 82 | sourceUpdatedAt: UINT32_MAX, |
| @@ -83,12 +88,8 @@ function maximumResult(favoriteId) { | ||
| 83 | 88 | }; |
| 84 | 89 | } |
| 85 | 90 | |
| 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"); | |
| 92 | 93 | const maximumApiKey = maximumEncodedString(contracts.LIMITS.apiKeyUtf8Bytes); |
| 93 | 94 | const maximumFavorites = Array.from({ length: contracts.LIMITS.favorites }, (_, index) => { |
| 94 | 95 | const suffix = String(index).padStart(2, "0"); |
| @@ -103,12 +104,45 @@ const maximumFavorites = Array.from({ length: contracts.LIMITS.favorites }, (_, | ||
| 103 | 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 | 131 | const configurationMessages = codec.encodeConfiguration( |
| 107 | - "config-maximum", | |
| 132 | + maximumSequenceId, | |
| 108 | 133 | maximumFavorites, |
| 109 | 134 | contracts.KEY_STATUS.CONFIGURED, |
| 110 | 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 | 147 | // Maximal lapinFuteResults record per the configuration validators |
| 114 | 148 | // (packages/companion/src/configuration.js isStoredResults/isResultEntry): |
| @@ -118,7 +152,7 @@ const configurationMessages = codec.encodeConfiguration( | ||
| 118 | 152 | const maximumResultEntries = maximumFavorites.map((favorite) => ({ |
| 119 | 153 | favoriteId: favorite.id, |
| 120 | 154 | storedAt: Number.MAX_SAFE_INTEGER, |
| 121 | - result: maximumResult(favorite.id), | |
| 155 | + result: maximumResult(maximumRequestId, favorite.id), | |
| 122 | 156 | })); |
| 123 | 157 | if (!maximumResultEntries.every((entry) => contracts.isDepartureResult(entry.result))) { |
| 124 | 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 | 165 | const largestPhoneDictionaryBytes = Math.max(...sizes.slice(1)); |
| 132 | 166 | const largestConfigurationDictionaryBytes = Math.max(...configurationSizes); |
| 133 | 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 | 185 | const phoneRecordBytes = Buffer.byteLength(JSON.stringify({ |
| 135 | 186 | schemaVersion: contracts.SCHEMA_VERSION, |
| 136 | 187 | favorites: maximumFavorites, |
| @@ -198,12 +249,12 @@ const capacities = { | ||
| 198 | 249 | source: "README.md quality gates: two-departure refresh at most 2048 encoded bytes", |
| 199 | 250 | }, |
| 200 | 251 | 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", | |
| 203 | 254 | }, |
| 204 | 255 | 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", | |
| 207 | 258 | }, |
| 208 | 259 | configurationMessages: { |
| 209 | 260 | value: contracts.LIMITS.favorites + 2, |
| @@ -211,19 +262,19 @@ const capacities = { | ||
| 211 | 262 | }, |
| 212 | 263 | configurationEncodedBytes: { |
| 213 | 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 | 267 | 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", | |
| 219 | 270 | }, |
| 220 | 271 | 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", | |
| 223 | 274 | }, |
| 224 | 275 | appMessageEightKilobyteBytes: { |
| 225 | 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 | 279 | watchUnsentQueueMessages: { |
| 229 | 280 | value: 4, |
| @@ -231,7 +282,7 @@ const capacities = { | ||
| 231 | 282 | }, |
| 232 | 283 | xsArenaBudgetBytes: { |
| 233 | 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 | 287 | phoneRecordBytes: { |
| 237 | 288 | value: 16384, |
| @@ -276,6 +327,22 @@ const report = { | ||
| 276 | 327 | requestBytes: sizes[0], |
| 277 | 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 | 346 | maximumConfiguration: { |
| 280 | 347 | messages: configurationMessages.length, |
| 281 | 348 | encodedBytes: configurationEncodedBytes, |
| @@ -296,10 +363,13 @@ const report = { | ||
| 296 | 363 | invalidKeyStatusRecordBytes, |
| 297 | 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 | 367 | capacities, |
| 301 | 368 | budget, |
| 302 | 369 | pass: messages.length <= budget.steadyStateMessages |
| 370 | + && allMaximumMessageTypesMeasured | |
| 371 | + && maximumPhoneDictionariesFitInbox | |
| 372 | + && maximumWatchRequestsFitOutbox | |
| 303 | 373 | && bytes <= budget.steadyStateEncodedBytes |
| 304 | 374 | && sizes[0] <= budget.requestBytes |
| 305 | 375 | && 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 watch | 55 | +// An explicit runtime bound prevents an 8k-capable phone connection from |
| 37 | -// bootstrap (packages/watch/src/embeddedjs/main.js) constructs | 56 | +// 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 validators | 59 | // 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 validators | 147 | // 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.steadyStateMessages | 369 | pass: messages.length <= budget.steadyStateMessages |
| 370 | + && allMaximumMessageTypesMeasured | ||
| 371 | + && maximumPhoneDictionariesFitInbox | ||
| 372 | + && maximumWatchRequestsFitOutbox | ||
| 303 | && bytes <= budget.steadyStateEncodedBytes | 373 | && bytes <= budget.steadyStateEncodedBytes |
| 304 | && sizes[0] <= budget.requestBytes | 374 | && sizes[0] <= budget.requestBytes |
| 305 | && largestPhoneDictionaryBytes <= budget.phoneDictionaryBytes | 375 | && largestPhoneDictionaryBytes <= budget.phoneDictionaryBytes |
modified
scripts/test.mjs +1 -0 | @@ -25,5 +25,6 @@ run(process.execPath, [ | ||
| 25 | 25 | "packages/watch/test/model.test.js", |
| 26 | 26 | "packages/watch/test/storage.test.js", |
| 27 | 27 | "packages/watch/test/localization.test.js", |
| 28 | + "packages/watch/test/presentation.test.js", | |
| 28 | 29 | "packages/watch/test/fixture-boot.test.js", |
| 29 | 30 | ]); |
| @@ -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 | ]); |