feat: deliver Pebble watch consultation #15
Merged@github-anthodev wants to merge
feature/watch-consultation/watch/7 into developMerged as
aabca5b95566.25 files changed
modified
packages/companion/src/index.js +273 -104 Large diff collapsed to keep the page fast — load it when you need it.
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 Large diff collapsed to keep the page fast — load it when you need it.
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 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/watch/src/embeddedjs/presentation.js +108 -0 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/watch/src/embeddedjs/protocol.js +273 -132 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/watch/src/embeddedjs/storage.js +93 -57 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/watch/src/embeddedjs/ui.js +294 -88 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/watch/test/controller-storage.test.js +520 -27 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/watch/test/fixture-boot.test.js +487 -51 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/watch/test/localization.test.js +62 -21 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/watch/test/manifest.test.js +20 -14 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/watch/test/model.test.js +373 -153 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/watch/test/presentation.test.js +527 -0 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/watch/test/protocol.test.js +152 -22 Large diff collapsed to keep the page fast — load it when you need it.
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 Large diff collapsed to keep the page fast — load it when you need it.
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 | ]); |