dsh-offwork 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +50 -32
  3. package/assets/animations/NOTICE.md +15 -0
  4. package/assets/animations/alpha-probe.webm +0 -0
  5. package/assets/animations/ornate-drink.webm +0 -0
  6. package/assets/animations/ornate-greet.webm +0 -0
  7. package/assets/animations/ornate-idle.webm +0 -0
  8. package/assets/animations/whale-drink.webm +0 -0
  9. package/assets/animations/whale-greet.webm +0 -0
  10. package/assets/characters/NOTICE.md +4 -0
  11. package/assets/characters/whale-actions.webp +0 -0
  12. package/assets/characters/whale-ornate-actions.webp +0 -0
  13. package/client.js +2970 -770
  14. package/client.source.js +1668 -444
  15. package/companion.js +6 -9
  16. package/countdown.js +83 -0
  17. package/hydration-store.js +55 -16
  18. package/hydration.js +231 -250
  19. package/index.js +2 -0
  20. package/locale/en.json +1 -1
  21. package/locale/zh.json +1 -1
  22. package/media-manifest.js +29 -0
  23. package/media-server.js +123 -0
  24. package/package.json +9 -2
  25. package/pet-interactions.js +78 -7
  26. package/pet-motion.js +111 -0
  27. package/pet-reminder.js +20 -0
  28. package/pet-surfaces.js +130 -6
  29. package/pet-video.js +380 -0
  30. package/pet-view.js +129 -13
  31. package/tests/classic-overview.test.mjs +77 -0
  32. package/tests/companion.test.mjs +12 -12
  33. package/tests/countdown.test.mjs +115 -0
  34. package/tests/host.test.mjs +6 -0
  35. package/tests/hydration-extras.test.mjs +168 -204
  36. package/tests/hydration-store.test.mjs +199 -213
  37. package/tests/hydration.test.mjs +116 -186
  38. package/tests/media-server.test.mjs +179 -0
  39. package/tests/pet-character.test.mjs +8 -2
  40. package/tests/pet-motion.test.mjs +139 -0
  41. package/tests/pet-reminder.test.mjs +71 -0
  42. package/tests/pet-video.test.mjs +503 -0
  43. package/tools/build.mjs +62 -13
  44. package/tools/check-package.mjs +19 -0
  45. package/ui-utils.js +51 -1
@@ -2,286 +2,272 @@ import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { IDBFactory } from 'fake-indexeddb';
4
4
  import { createHydrationStore } from '../hydration-store.js';
5
- import { calculate } from '../clock.js';
6
- import {
7
- defaultHydration,
8
- hydrationContext,
9
- hydrationView,
10
- transitionHydration,
11
- } from '../hydration.js';
12
-
13
- const settings = {
14
- payMode: 'monthly',
15
- monthlySalary: 12000,
16
- workdaysPerMonth: 20,
17
- start: '09:00',
18
- end: '18:00',
19
- breakStart: '12:00',
20
- breakEnd: '13:00',
21
- weekdays: [1, 2, 3, 4, 5],
22
- timezone: 'Asia/Shanghai',
23
- };
24
- const begin = Date.parse('2026-11-04T09:00:00+08:00');
25
- const due = begin + 3600000;
26
- const context = (at, patch = {}) => {
27
- const config = { ...settings, ...patch };
28
- return hydrationContext(config, calculate(config, at));
29
- };
30
- const read = (store) => store.update((state) => state);
5
+ import { defaultHydration, hydrationView, transitionHydration } from '../hydration.js';
6
+ const name = 'dsh.offwork-clock.hydration.v1',
7
+ begin = Date.parse('2026-11-06T20:00:00+08:00'),
8
+ due = begin + 3600000;
9
+ const read = (store) => store.update((s) => s);
10
+ const raw = (idb, storeName, value, version) =>
11
+ new Promise((resolve, reject) => {
12
+ const r = version ? idb.open(name, version) : idb.open(name);
13
+ r.onupgradeneeded = () => {
14
+ if (!r.result.objectStoreNames.contains(storeName)) r.result.createObjectStore(storeName);
15
+ };
16
+ r.onerror = () => reject(r.error);
17
+ r.onsuccess = () => {
18
+ const db = r.result,
19
+ tx = db.transaction(storeName, value === undefined ? 'readonly' : 'readwrite'),
20
+ s = tx.objectStore(storeName);
21
+ let result;
22
+ if (value !== undefined) s.put(value, 'current');
23
+ else {
24
+ const q = s.get('current');
25
+ q.onsuccess = () => (result = q.result);
26
+ }
27
+ tx.oncomplete = () => {
28
+ db.close();
29
+ resolve(result);
30
+ };
31
+ tx.onabort = () => {
32
+ db.close();
33
+ reject(tx.error);
34
+ };
35
+ };
36
+ });
37
+ const legacy = (schema = 2) => ({
38
+ schema,
39
+ revision: 40,
40
+ enabled: true,
41
+ interval: 5,
42
+ shift: {
43
+ id: 'retained-shift',
44
+ schedule: 'retained-schedule',
45
+ config: 'retained-config',
46
+ count: 9,
47
+ paused: true,
48
+ due: 300000,
49
+ sequence: 4,
50
+ claim: null,
51
+ ...(schema === 2 ? { quietUntil: begin + 1800000, recentActions: ['old-cup'] } : {}),
52
+ },
53
+ });
31
54
  const fixture = (t) => {
32
- const indexedDB = new IDBFactory();
33
- const first = createHydrationStore(indexedDB);
34
- const second = createHydrationStore(indexedDB);
55
+ const idb = new IDBFactory(),
56
+ first = createHydrationStore(idb, name, () => begin),
57
+ second = createHydrationStore(idb, name, () => begin);
35
58
  t.after(() => {
36
59
  first.close();
37
60
  second.close();
38
61
  });
39
- return { indexedDB, first, second };
62
+ return { idb, first, second };
40
63
  };
41
64
  const seed = (store) =>
42
- store.update((state) =>
43
- transitionHydration(
44
- state,
45
- context(begin),
46
- { type: 'preferences', enabled: true, interval: 60 },
47
- begin,
48
- 'a',
49
- ),
65
+ store.update((s) =>
66
+ transitionHydration(s, { type: 'preferences', enabled: true, interval: 60 }, begin, 'a'),
50
67
  );
51
- const rawWrite = (indexedDB, state) =>
52
- new Promise((resolve, reject) => {
53
- const request = indexedDB.open('dsh.offwork-clock.hydration.v1', 1);
54
- request.onsuccess = () => {
55
- const db = request.result;
56
- const transaction = db.transaction('state', 'readwrite');
57
- transaction.objectStore('state').put(state, 'current');
58
- transaction.oncomplete = () => {
59
- db.close();
60
- resolve();
61
- };
62
- transaction.onabort = () => {
63
- db.close();
64
- reject(transaction.error);
65
- };
66
- };
67
- request.onerror = () => reject(request.error);
68
- });
69
-
70
- test('read-only first load does not create a default record; committed state survives reopening', async (t) => {
71
- const { indexedDB, first } = fixture(t);
68
+ test('fresh reads do not write a default record and committed deadlines survive reopening', async (t) => {
69
+ const { idb, first } = fixture(t);
72
70
  assert.deepEqual(await read(first), defaultHydration());
71
+ assert.equal(await raw(idb, 'wall-clock'), undefined);
73
72
  const state = await seed(first);
74
73
  first.close();
75
- const reopened = createHydrationStore(indexedDB);
74
+ const reopened = createHydrationStore(idb);
76
75
  t.after(() => reopened.close());
77
76
  assert.deepEqual(await read(reopened), state);
78
- assert.ok(state.revision > 0);
79
77
  });
80
-
81
- test('transactions serialize competing claims and duplicate processing across connections', async (t) => {
78
+ test('competing leases and repeated reminder acknowledgments serialize across connections', async (t) => {
82
79
  const { first, second } = fixture(t);
83
80
  await seed(first);
84
- await Promise.all([
85
- first.update((s) => transitionHydration(s, context(due), { type: 'claim' }, due, 'a')),
86
- second.update((s) => transitionHydration(s, context(due), { type: 'claim' }, due, 'b')),
87
- ]);
88
- const claimed = await read(first);
89
- const owner = claimed.shift.claim.owner;
90
- const eventId = hydrationView(claimed, context(due)).eventId;
91
81
  await Promise.all(
92
- [first, second].map((store) =>
93
- store.update((s) =>
94
- transitionHydration(s, context(due), { type: 'drink', eventId }, due, owner),
95
- ),
82
+ [first, second].map((s, i) =>
83
+ s.update((v) => transitionHydration(v, { type: 'claim' }, due, i ? 'b' : 'a')),
84
+ ),
85
+ );
86
+ const claimed = await read(first),
87
+ eventId = hydrationView(claimed, due).eventId;
88
+ await Promise.all(
89
+ [first, second].map((s) =>
90
+ s.update((v) => transitionHydration(v, { type: 'drink', eventId }, due, claimed.claim.owner)),
96
91
  ),
97
92
  );
98
- assert.equal((await read(first)).shift.count, 1);
93
+ assert.equal((await read(first)).count, 1);
99
94
  assert.deepEqual(await read(first), await read(second));
100
95
  });
101
-
102
- test('stale tabs cannot revert a changed schedule with tick, release, pause, or clear actions', async (t) => {
96
+ test('concurrent manual records deduplicate the same ID and retain independent IDs without Host settings', async (t) => {
97
+ const { first, second } = fixture(t);
98
+ const record = (store, id) =>
99
+ store.update((s) => transitionHydration(s, { type: 'manual-drink', actionId: id }, begin, 'a'));
100
+ await Promise.all([record(first, 'same'), record(second, 'same')]);
101
+ assert.equal((await read(first)).count, 1);
102
+ await Promise.all([record(first, 'one'), record(second, 'two')]);
103
+ assert.equal((await read(first)).count, 3);
104
+ });
105
+ test('foreign release does not erase ownership, while the owner can release without a schedule', async (t) => {
103
106
  const { first, second } = fixture(t);
104
107
  await seed(first);
105
- const initial = await read(first);
106
- const current = await first.update((s) =>
107
- transitionHydration(
108
- s,
109
- context(due, { end: '19:00' }),
110
- { type: 'tick', baseSchedule: initial.shift.schedule },
111
- due,
112
- 'a',
113
- ),
114
- );
115
- assert.notEqual(current.shift.schedule, initial.shift.schedule);
116
- for (const type of ['tick', 'claim', 'release', 'pause', 'resume', 'clear-count']) {
117
- await second.update((s) => transitionHydration(s, context(due), { type }, due, 'b'));
118
- assert.deepEqual(await read(first), current, type);
119
- }
120
- // Even a newly opened stale tab cannot revert a timezone change that changes the shift id.
121
- const timezone = await first.update((s) =>
122
- transitionHydration(
123
- s,
124
- context(due, { timezone: 'UTC', end: '19:00' }),
125
- { type: 'tick', baseSchedule: current.shift.schedule },
126
- due,
127
- 'a',
128
- ),
129
- );
130
- await second.update((s) => transitionHydration(s, context(due), { type: 'tick' }, due, 'b'));
131
- assert.deepEqual(await read(first), timezone);
108
+ const claimed = await first.update((s) => transitionHydration(s, { type: 'claim' }, due, 'a'));
109
+ await second.update((s) => transitionHydration(s, { type: 'release' }, due, 'b'));
110
+ assert.deepEqual(await read(first), claimed);
111
+ await first.update((s) => transitionHydration(s, { type: 'release' }, due, 'a'));
112
+ assert.equal((await read(first)).claim, null);
132
113
  });
133
-
134
- test('a tab without Host settings cannot erase another tab claim', async (t) => {
114
+ test('partial preference updates preserve another tab interval change', async (t) => {
135
115
  const { first, second } = fixture(t);
136
116
  await seed(first);
137
- const claimed = await first.update((s) =>
138
- transitionHydration(s, context(due), { type: 'claim' }, due, 'a'),
117
+ await first.update((s) =>
118
+ transitionHydration(s, { type: 'preferences', interval: 30 }, begin, 'a'),
139
119
  );
140
- await second.update((s) => transitionHydration(s, null, { type: 'release' }, due, 'b'));
141
- assert.deepEqual(await read(first), claimed);
142
- await first.update((s) => transitionHydration(s, null, { type: 'release' }, due, 'a'));
143
- assert.equal((await read(first)).shift.claim, null);
120
+ await second.update((s) =>
121
+ transitionHydration(s, { type: 'preferences', enabled: false }, begin, 'b'),
122
+ );
123
+ const state = await read(first);
124
+ assert.equal(state.interval, 30);
125
+ assert.equal(state.enabled, false);
144
126
  });
145
-
146
- test('explicit reset is monotonic and unknown data is rejected without overwriting', async (t) => {
147
- const { indexedDB, first, second } = fixture(t);
127
+ for (const schema of [1, 2])
128
+ test(`legacy schema ${schema} migrates once in a shared transaction and keeps the original snapshot`, async (t) => {
129
+ const { idb, first, second } = fixture(t),
130
+ old = legacy(schema);
131
+ await raw(idb, 'state', old, 1);
132
+ const [a, b] = await Promise.all([read(first), read(second)]);
133
+ assert.equal(a.schema, 3);
134
+ assert.equal(a.revision, 41);
135
+ assert.deepEqual(a, b);
136
+ assert.equal(a.count, 9);
137
+ assert.equal(a.interval, 5);
138
+ assert.equal(a.paused, true);
139
+ assert.equal(a.claim, null);
140
+ assert.deepEqual(a.recentActions, schema === 2 ? ['old-cup'] : []);
141
+ assert.equal(a.quietUntil, schema === 2 ? begin + 1800000 : 0);
142
+ assert.deepEqual(await raw(idb, 'state'), old);
143
+ assert.deepEqual(await read(second), a);
144
+ const oldClient = new Promise((resolve, reject) => {
145
+ const r = idb.open(name, 1);
146
+ r.onerror = () => reject(r.error);
147
+ r.onsuccess = () => {
148
+ r.result.close();
149
+ resolve();
150
+ };
151
+ });
152
+ await assert.rejects(oldClient, { name: 'VersionError' });
153
+ await first.update(() => defaultHydration(), { reset: true });
154
+ assert.equal((await read(second)).count, 0);
155
+ assert.equal((await read(second)).enabled, false);
156
+ assert.deepEqual(await raw(idb, 'state'), old);
157
+ });
158
+ test('invalid legacy records abort migration without overwriting either record; explicit reset does not reimport', async (t) => {
159
+ const { idb, first } = fixture(t),
160
+ bad = { schema: 99, revision: 40 };
161
+ await raw(idb, 'state', bad, 1);
162
+ await assert.rejects(read(first), /Invalid legacy/);
163
+ assert.equal(await raw(idb, 'wall-clock'), undefined);
164
+ assert.deepEqual(await raw(idb, 'state'), bad);
165
+ const reset = await first.update(() => defaultHydration(), { reset: true });
166
+ assert.equal(reset.count, 0);
167
+ assert.deepEqual(await read(first), reset);
168
+ });
169
+ test('invalid new records fail closed and explicit reset increments their stored revision', async (t) => {
170
+ const { idb, first, second } = fixture(t);
148
171
  await seed(first);
149
- await rawWrite(indexedDB, { schema: 99, revision: 40 });
172
+ await raw(idb, 'wall-clock', { schema: 99, revision: 40 });
150
173
  await assert.rejects(read(second), /Invalid hydration data/);
151
174
  const reset = await first.update(() => defaultHydration(), { reset: true });
152
175
  assert.equal(reset.revision, 41);
153
- assert.equal(reset.enabled, false);
154
- assert.equal(reset.shift, null);
176
+ assert.equal(reset.count, 0);
155
177
  assert.deepEqual(await read(second), reset);
156
178
  });
157
-
158
- test('mutating reducers and aborting reducers cannot silently lose or partially commit updates', async (t) => {
159
- const { first } = fixture(t);
179
+ test('mutating and aborting reducers are atomic, including revision exhaustion', async (t) => {
180
+ const { idb, first } = fixture(t);
160
181
  await seed(first);
161
182
  const state = await first.update((s) => {
162
183
  s.interval = 30;
163
184
  return s;
164
185
  });
165
- assert.equal((await read(first)).interval, 30);
166
186
  await assert.rejects(
167
187
  first.update((s) => {
168
- s.interval = 10;
188
+ s.interval = 5;
169
189
  throw new Error('abort');
170
190
  }),
171
191
  /abort/,
172
192
  );
173
193
  assert.deepEqual(await read(first), state);
174
- });
175
-
176
- test('partial preference updates preserve changes made by another connection', async (t) => {
177
- const { first, second } = fixture(t);
178
- await seed(first);
179
- await first.update((s) =>
180
- transitionHydration(s, context(begin), { type: 'preferences', interval: 30 }, begin, 'a'),
181
- );
182
- await second.update((s) =>
183
- transitionHydration(s, context(begin), { type: 'preferences', enabled: false }, begin, 'b'),
194
+ const exhausted = { ...state, revision: Number.MAX_SAFE_INTEGER };
195
+ await raw(idb, 'wall-clock', exhausted);
196
+ await assert.rejects(
197
+ first.update((s) => ({ ...s, interval: 5 })),
198
+ /revision exhausted/,
184
199
  );
185
- const state = await read(first);
186
- assert.equal(state.interval, 30);
187
- assert.equal(state.enabled, false);
200
+ assert.deepEqual(await read(first), exhausted);
188
201
  });
189
-
190
- test('transient opening errors recover and unavailable storage rejects explicitly', async () => {
202
+ test('transient opening errors recover while missing storage rejects explicitly', async () => {
203
+ const idb = new IDBFactory();
191
204
  let calls = 0;
192
- const indexedDB = new IDBFactory();
193
205
  const store = createHydrationStore({
194
206
  open(...args) {
195
207
  if (++calls === 1) throw new Error('temporary');
196
- return indexedDB.open(...args);
208
+ return idb.open(...args);
197
209
  },
198
210
  });
199
211
  await assert.rejects(read(store), /temporary/);
200
212
  assert.deepEqual(await read(store), defaultHydration());
201
213
  assert.equal(calls, 2);
202
214
  store.close();
203
- const unavailable = createHydrationStore(null);
204
- await assert.rejects(read(unavailable), /unavailable/);
205
- unavailable.close();
215
+ const missing = createHydrationStore(null);
216
+ await assert.rejects(read(missing), /unavailable/);
217
+ missing.close();
206
218
  });
207
-
208
- test('legacy records migrate atomically once without losing user data', async (t) => {
209
- const { indexedDB, first, second } = fixture(t);
210
- const legacy = await seed(first);
211
- legacy.schema = 1;
212
- legacy.revision = 40;
213
- legacy.interval = 5;
214
- legacy.shift.count = 9;
215
- legacy.shift.paused = true;
216
- delete legacy.shift.quietUntil;
217
- delete legacy.shift.recentActions;
218
- await rawWrite(indexedDB, legacy);
219
- const [a, b] = await Promise.all([read(first), read(second)]);
220
- assert.equal(a.schema, 2);
221
- assert.equal(a.revision, 41);
222
- assert.deepEqual(a, b);
223
- assert.equal(a.interval, 5);
224
- assert.equal(a.shift.count, 9);
225
- assert.equal(a.shift.paused, true);
226
- assert.equal(a.shift.due, legacy.shift.due);
227
- assert.deepEqual(await read(second), a);
228
- });
229
-
230
- test('concurrent check-ins deduplicate the same ID but retain independent IDs', async (t) => {
231
- const { first, second } = fixture(t);
232
- await seed(first);
233
- const record = (store, id) =>
234
- store.update((s) =>
235
- transitionHydration(
236
- s,
237
- context(begin),
238
- {
239
- type: 'manual-drink',
240
- actionId: id,
241
- shiftId: context(begin).id,
242
- },
243
- begin,
244
- 'a',
245
- ),
246
- );
247
- await Promise.all([record(first, 'same'), record(second, 'same')]);
248
- assert.equal((await read(first)).shift.count, 1);
249
- await Promise.all([record(first, 'one'), record(second, 'two')]);
250
- assert.equal((await read(first)).shift.count, 3);
219
+ test('a blocked upgrade reports failure and can retry after the old connection closes', async (t) => {
220
+ const { idb, first } = fixture(t);
221
+ await raw(idb, 'state', legacy(), 1);
222
+ const old = await new Promise((resolve, reject) => {
223
+ const r = idb.open(name, 1);
224
+ r.onsuccess = () => resolve(r.result);
225
+ r.onerror = () => reject(r.error);
226
+ });
227
+ await assert.rejects(read(first), /blocked/);
228
+ old.close();
229
+ const migrated = await read(first);
230
+ assert.equal(migrated.count, 9);
231
+ assert.equal(migrated.schema, 3);
251
232
  });
252
-
253
- test('exhausted revisions abort an update instead of persisting invalid data', async (t) => {
254
- const { indexedDB, first } = fixture(t);
255
- const current = await seed(first);
256
- current.revision = Number.MAX_SAFE_INTEGER;
257
- await rawWrite(indexedDB, current);
258
- await assert.rejects(
259
- first.update((s) => ({ ...s, interval: 5 })),
260
- /revision exhausted/,
261
- );
262
- assert.deepEqual(await read(first), current);
233
+ test('deleting a migrated current record never resurrects the retained legacy count', async (t) => {
234
+ const { idb, first } = fixture(t);
235
+ await raw(idb, 'state', legacy(), 1);
236
+ await read(first);
237
+ await new Promise((resolve, reject) => {
238
+ const request = idb.open(name);
239
+ request.onerror = () => reject(request.error);
240
+ request.onsuccess = () => {
241
+ const db = request.result,
242
+ tx = db.transaction('wall-clock', 'readwrite');
243
+ tx.objectStore('wall-clock').delete('current');
244
+ tx.oncomplete = () => {
245
+ db.close();
246
+ resolve();
247
+ };
248
+ tx.onabort = () => {
249
+ db.close();
250
+ reject(tx.error);
251
+ };
252
+ };
253
+ });
254
+ const next = await read(first);
255
+ assert.equal(next.count, 0);
256
+ assert.equal(next.enabled, false);
257
+ assert.equal((await raw(idb, 'state')).shift.count, 9);
263
258
  });
264
259
 
265
- test('quiet and manual actions preserve each other across connections', async (t) => {
260
+ test('quiet time and manual drinking preserve each other across connections', async (t) => {
266
261
  const { first, second } = fixture(t);
267
262
  await seed(first);
268
263
  await Promise.all([
269
- first.update((s) =>
270
- transitionHydration(s, context(begin), { type: 'quiet', minutes: 30 }, begin, 'a'),
271
- ),
264
+ first.update((s) => transitionHydration(s, { type: 'quiet', minutes: 60 }, begin, 'a')),
272
265
  second.update((s) =>
273
- transitionHydration(
274
- s,
275
- context(begin),
276
- { type: 'manual-drink', actionId: 'cup', shiftId: context(begin).id },
277
- begin,
278
- 'b',
279
- ),
266
+ transitionHydration(s, { type: 'manual-drink', actionId: 'cup' }, begin, 'b'),
280
267
  ),
281
268
  ]);
282
269
  const state = await read(first);
283
- assert.equal(state.shift.count, 1);
284
- assert.equal(state.shift.quietUntil, begin + 1800000);
285
- assert.equal(hydrationView(state, context(begin), begin).phase, 'quiet');
286
- assert.deepEqual(await read(second), state);
270
+ assert.equal(state.count, 1);
271
+ assert.equal(state.quietUntil, begin + 3600000);
272
+ assert.equal(state.dueAt, begin + 3600000);
287
273
  });