dsh-offwork 1.1.1 → 1.2.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 (44) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +48 -26
  3. package/ROADMAP.md +70 -0
  4. package/client.js +5066 -1099
  5. package/client.source.js +1988 -541
  6. package/companion-app.js +1431 -0
  7. package/companion-image.js +59 -0
  8. package/companion-state.js +424 -0
  9. package/companion.js +6 -9
  10. package/countdown.js +83 -0
  11. package/docs/RELEASE-1.2.2.md +20 -0
  12. package/hydration-store.js +55 -16
  13. package/hydration.js +231 -250
  14. package/icon.svg +28 -1
  15. package/index.js +59 -6
  16. package/locale/en.json +1 -1
  17. package/locale/zh.json +1 -1
  18. package/media-manifest.js +29 -0
  19. package/media-server.js +123 -0
  20. package/package.json +13 -3
  21. package/pet-interactions.js +121 -17
  22. package/pet-motion.js +2 -1
  23. package/pet-reminder.js +20 -0
  24. package/pet-surfaces.js +130 -6
  25. package/pet-video.js +221 -72
  26. package/pet-view.js +71 -4
  27. package/settings-api.js +126 -0
  28. package/tests/classic-overview.test.mjs +77 -0
  29. package/tests/companion-state.test.mjs +267 -0
  30. package/tests/companion.test.mjs +12 -12
  31. package/tests/countdown.test.mjs +115 -0
  32. package/tests/host.test.mjs +26 -13
  33. package/tests/hydration-extras.test.mjs +168 -204
  34. package/tests/hydration-store.test.mjs +199 -213
  35. package/tests/hydration.test.mjs +116 -186
  36. package/tests/media-server.test.mjs +179 -0
  37. package/tests/pet-interactions.test.mjs +19 -0
  38. package/tests/pet-reminder.test.mjs +71 -0
  39. package/tests/pet-video.test.mjs +171 -5
  40. package/tests/settings-api.test.mjs +130 -0
  41. package/tests/ui.test.mjs +4 -1
  42. package/tools/build.mjs +45 -16
  43. package/tools/check-package.mjs +12 -0
  44. package/ui-utils.js +44 -0
@@ -0,0 +1,126 @@
1
+ import { mkdir, readFile, writeFile, rename, unlink } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+
5
+ export const SETTINGS_ROUTE = '/offwork-clock/settings';
6
+ export function createSettingsAccess(directory) {
7
+ const file = join(directory, 'agent-access.json');
8
+ let queue = Promise.resolve();
9
+ return {
10
+ async get() {
11
+ try {
12
+ const value = JSON.parse(await readFile(file, 'utf8'));
13
+ if (value.schema !== 1 || typeof value.allowed !== 'boolean')
14
+ throw new Error('Invalid access policy');
15
+ return value.allowed;
16
+ } catch (error) {
17
+ if (error.code === 'ENOENT') return false;
18
+ throw error;
19
+ }
20
+ },
21
+ set(allowed) {
22
+ const result = queue.then(async () => {
23
+ if (typeof allowed !== 'boolean') throw new Error('Invalid access policy');
24
+ await mkdir(directory, { recursive: true, mode: 0o700 });
25
+ const temp = join(directory, `.access-${randomUUID()}.tmp`);
26
+ try {
27
+ await writeFile(temp, JSON.stringify({ schema: 1, allowed }), {
28
+ mode: 0o600,
29
+ flag: 'wx',
30
+ });
31
+ await rename(temp, file);
32
+ } finally {
33
+ await unlink(temp).catch(() => {});
34
+ }
35
+ return allowed;
36
+ });
37
+ queue = result.catch(() => {});
38
+ return result;
39
+ },
40
+ };
41
+ }
42
+
43
+ // Served under the Harness host's existing access boundary. Never enables CORS.
44
+ // A custom header is required even for GET; cross-origin browsers cannot send it
45
+ // without a successful preflight. Same-site subdomains are not trusted.
46
+ export function createSettingsHandler(operate, access, authorize = () => 401) {
47
+ return async (req, res) => {
48
+ const send = (status, body) => {
49
+ if (res.destroyed || res.writableEnded) return;
50
+ res.writeHead(status, {
51
+ 'Content-Type': 'application/json; charset=utf-8',
52
+ 'Cache-Control': 'no-store',
53
+ 'X-Content-Type-Options': 'nosniff',
54
+ });
55
+ res.end(JSON.stringify(body));
56
+ };
57
+ if (!['GET', 'POST'].includes(req.method))
58
+ return send(405, { ok: false, error: 'Method not allowed' });
59
+ const site = req.headers['sec-fetch-site'];
60
+ let originOK = true;
61
+ if (req.headers.origin) {
62
+ try {
63
+ originOK = new URL(req.headers.origin).host === req.headers.host;
64
+ } catch {
65
+ originOK = false;
66
+ }
67
+ }
68
+ if (
69
+ req.headers['x-offwork-client'] !== '1' ||
70
+ (site && !['same-origin', 'none'].includes(site)) ||
71
+ !originOK
72
+ )
73
+ return send(403, { ok: false, error: 'Same-origin client required' });
74
+ const rejection = authorize(req);
75
+ if (rejection) return send(rejection, { ok: false, error: 'Authenticated owner required' });
76
+ try {
77
+ if (req.method === 'GET') {
78
+ const result = await operate('get');
79
+ return send(result.ok ? 200 : 422, { ...result, agentAccess: await access.get() });
80
+ }
81
+ if (!/^application\/json(?:;|$)/i.test(req.headers['content-type'] || ''))
82
+ return send(415, { ok: false, error: 'JSON required' });
83
+ const chunks = [];
84
+ let size = 0;
85
+ for await (const chunk of req) {
86
+ size += chunk.length;
87
+ if (size > 16384) return send(413, { ok: false, error: 'Request too large' });
88
+ chunks.push(chunk);
89
+ }
90
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
91
+ if (!body || typeof body !== 'object' || Array.isArray(body))
92
+ throw new Error('Invalid request');
93
+ if (body.action === 'access') {
94
+ if (Object.keys(body).some((k) => !['action', 'allowed'].includes(k)))
95
+ throw new Error('Unknown field');
96
+ return send(200, { ok: true, agentAccess: await access.set(body.allowed) });
97
+ }
98
+ if (
99
+ body.action !== 'set' ||
100
+ Object.keys(body).some((k) => !['action', 'settings'].includes(k))
101
+ )
102
+ throw new Error('Invalid action');
103
+ const result = await operate('set', body.settings);
104
+ send(result.ok ? 200 : 422, result);
105
+ } catch {
106
+ send(400, {
107
+ ok: false,
108
+ error: '无法读取或保存设置,请检查配置和访问权限。 / Unable to read or save settings.',
109
+ });
110
+ }
111
+ };
112
+ }
113
+ export function registerSettingsRoute(ctx, operate, access) {
114
+ ctx.inject?.(['webServer', 'connection'], (webCtx) => {
115
+ const authorize = (request) => {
116
+ if (typeof webCtx.connection?.admit !== 'function') return 503;
117
+ const admission = webCtx.connection.admit(request);
118
+ if ('rejection' in admission) return admission.rejection;
119
+ return admission.peer.principal.kind === 'owner' ? undefined : 403;
120
+ };
121
+ const handler = createSettingsHandler(operate, access, authorize);
122
+ webCtx.effect(() =>
123
+ webCtx.webServer.register({ kind: 'exact', path: SETTINGS_ROUTE, handler }),
124
+ );
125
+ });
126
+ }
@@ -0,0 +1,77 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { classicClockPresentation } from '../ui-utils.js';
4
+ import { calculate } from '../clock.js';
5
+ const settings = {
6
+ payMode: 'monthly',
7
+ monthlySalary: 10000,
8
+ workdaysPerMonth: 21.75,
9
+ dailySalary: 0,
10
+ start: '09:00',
11
+ end: '18:00',
12
+ breakStart: '12:00',
13
+ breakEnd: '13:00',
14
+ weekdays: [1, 2, 3, 4, 5],
15
+ timezone: 'Asia/Shanghai',
16
+ };
17
+ const present = (time, config = settings) => {
18
+ const now = Date.parse(time);
19
+ return classicClockPresentation({
20
+ hasSession: true,
21
+ loaded: true,
22
+ configured: true,
23
+ failed: false,
24
+ state: calculate(config, now),
25
+ now,
26
+ });
27
+ };
28
+ test('classic overview distinguishes startup, missing configuration and failed reads without a zero countdown', () => {
29
+ const base = {
30
+ hasSession: true,
31
+ loaded: true,
32
+ configured: true,
33
+ failed: false,
34
+ state: null,
35
+ now: 0,
36
+ };
37
+ for (const [patch, phase] of [
38
+ [{ hasSession: false }, 'waiting'],
39
+ [{ loaded: false }, 'loading'],
40
+ [{ configured: false }, 'setup'],
41
+ [{ failed: true }, 'error'],
42
+ ]) {
43
+ const view = classicClockPresentation({ ...base, ...patch });
44
+ assert.equal(view.phase, phase);
45
+ assert.equal(view.seconds, null);
46
+ }
47
+ });
48
+ test('classic overview shows the correct countdown target for before, working and lunch', () => {
49
+ assert.equal(present('2026-11-05T00:00:00Z').seconds, 3600);
50
+ const working = present('2026-11-05T02:00:00Z');
51
+ assert.equal(working.phase, 'working');
52
+ assert.equal(working.seconds, 8 * 3600);
53
+ const lunch = present('2026-11-05T04:30:00Z');
54
+ assert.equal(lunch.phase, 'lunch');
55
+ assert.equal(lunch.seconds, 1800);
56
+ });
57
+ test('completed shifts and rest days show a state instead of a meaningless zero', () => {
58
+ for (const [date, phase] of [
59
+ ['2026-11-05T11:00:00Z', 'done'],
60
+ ['2026-11-07T02:00:00Z', 'rest'],
61
+ ]) {
62
+ const result = present(date);
63
+ assert.equal(result.phase, phase);
64
+ assert.equal(result.seconds, null);
65
+ }
66
+ const night = {
67
+ ...settings,
68
+ start: '22:00',
69
+ end: '06:00',
70
+ breakStart: '02:00',
71
+ breakEnd: '02:00',
72
+ };
73
+ assert.equal(present('2026-11-05T15:00:00Z', night).seconds, 7 * 3600);
74
+ const completed = present('2026-11-05T23:00:00Z', night);
75
+ assert.equal(completed.phase, 'done');
76
+ assert.equal(completed.seconds, null);
77
+ });
@@ -0,0 +1,267 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { IDBFactory } from 'fake-indexeddb';
4
+ import { defaultCountdown, transitionCountdown } from '../countdown.js';
5
+ import { defaultHydration, transitionHydration } from '../hydration.js';
6
+ import { createStateStore } from '../hydration-store.js';
7
+ import {
8
+ defaultCompanionState,
9
+ validateCompanionState,
10
+ companionEvents,
11
+ companionPending,
12
+ companionTransition,
13
+ reconcileCompanion,
14
+ companionDaySummary,
15
+ validateBrowserBackup,
16
+ companionBackupStamp,
17
+ companionStateStamp,
18
+ createCompanionRecoveryStore,
19
+ } from '../companion-state.js';
20
+ const at = Date.parse('2026-11-12T09:00:00Z');
21
+ const event = { id: 'smoking:1', kind: 'smoking', label: 'Smoking', dueAt: at };
22
+ const fresh = () => reconcileCompanion(defaultCompanionState(), [event], at);
23
+
24
+ test('closing, snoozing and quiet mode have distinct persistent semantics', () => {
25
+ let state = fresh();
26
+ state = companionTransition(state, { type: 'dismiss', id: event.id }, at);
27
+ assert.equal(companionPending(state, [event], at).length, 0);
28
+ assert.equal(state.deliveries[event.id].dueAt, at);
29
+ state = companionTransition(state, { type: 'snooze', id: event.id }, at);
30
+ assert.equal(companionPending(state, [event], at + 599999).length, 0);
31
+ assert.equal(companionPending(state, [event], at + 600000).length, 1);
32
+ state = companionTransition(
33
+ state,
34
+ { type: 'preferences', patch: { quietUntil: at + 700000 } },
35
+ at,
36
+ );
37
+ assert.equal(companionPending(state, [event], at + 600000).length, 0);
38
+ assert.equal(companionPending(state, [event], at + 700000).length, 1);
39
+ });
40
+ test('only one tab owns a reminder; lease can recover after a crash', async () => {
41
+ const idb = new IDBFactory();
42
+ const stores = ['a', 'b'].map(() =>
43
+ createStateStore(idb, 'race', defaultCompanionState, validateCompanionState),
44
+ );
45
+ await stores[0].update(() => fresh());
46
+ const winners = [];
47
+ await Promise.all(
48
+ stores.map((s, i) =>
49
+ s.update((value) => {
50
+ const next = companionTransition(
51
+ value,
52
+ { type: 'claim', id: event.id, owner: String(i) },
53
+ at,
54
+ );
55
+ if (next.deliveries[event.id].owner === String(i)) winners.push(i);
56
+ return next;
57
+ }),
58
+ ),
59
+ );
60
+ assert.equal(winners.length, 1);
61
+ const loser = String(1 - winners[0]);
62
+ const next = await stores[1].update((s) =>
63
+ companionTransition(s, { type: 'claim', id: event.id, owner: loser }, at + 8000),
64
+ );
65
+ assert.equal(next.deliveries[event.id].owner, loser);
66
+ stores.forEach((s) => s.close());
67
+ });
68
+ test('notification claims are atomic across tabs and snooze permits one new notification', async () => {
69
+ const idb = new IDBFactory();
70
+ const a = createStateStore(idb, 'notification', defaultCompanionState, validateCompanionState),
71
+ b = createStateStore(idb, 'notification', defaultCompanionState, validateCompanionState);
72
+ await a.update(() => fresh());
73
+ let sent = 0;
74
+ await Promise.all(
75
+ [a, b].map((store) =>
76
+ store.update((s) => {
77
+ if (!s.deliveries[event.id].notifiedAt) sent++;
78
+ return companionTransition(s, { type: 'notify', id: event.id }, at);
79
+ }),
80
+ ),
81
+ );
82
+ assert.equal(sent, 1);
83
+ let state = await a.update((s) => companionTransition(s, { type: 'snooze', id: event.id }, at));
84
+ assert.equal(state.deliveries[event.id].notifiedAt, 0);
85
+ state = companionTransition(state, { type: 'notify', id: event.id }, at + 1);
86
+ assert.equal(state.deliveries[event.id].notifiedAt, 0);
87
+ a.close();
88
+ b.close();
89
+ });
90
+ test('legacy water/smoking clocks are observed without modifying them', () => {
91
+ let smoking = transitionCountdown(defaultCountdown(), { type: 'start' }, at - 3600000);
92
+ let water = transitionHydration(
93
+ defaultHydration(),
94
+ { type: 'preferences', enabled: true, interval: 5 },
95
+ at - 300000,
96
+ 'test',
97
+ );
98
+ const before = JSON.stringify({ smoking, water });
99
+ const events = companionEvents(
100
+ defaultCompanionState(),
101
+ { smoking, water, smokingReady: true, waterReady: true },
102
+ at,
103
+ );
104
+ assert.deepEqual(events.map((e) => e.kind).sort(), ['smoking', 'water']);
105
+ assert.equal(JSON.stringify({ smoking, water }), before);
106
+ water.claim = { owner: 'older-client', until: at + 8000, sequence: water.sequence };
107
+ assert.equal(
108
+ companionEvents(
109
+ defaultCompanionState(),
110
+ { water, waterReady: true, waterOwner: 'new-client' },
111
+ at,
112
+ ).length,
113
+ 0,
114
+ );
115
+ assert.equal(
116
+ companionEvents(
117
+ defaultCompanionState(),
118
+ { water, waterReady: true, waterOwner: 'new-client' },
119
+ at + 8000,
120
+ ).length,
121
+ 1,
122
+ );
123
+ });
124
+ test('wrap-up completion suppresses this shift only and leaves water/custom clocks intact', () => {
125
+ let state = defaultCompanionState();
126
+ state.preferences.wrapEnabled = true;
127
+ const shift = {
128
+ isWorkday: true,
129
+ shiftDate: '2026-11-12',
130
+ shiftStart: at - 3600000,
131
+ shiftEnd: at + 300000,
132
+ };
133
+ const inputs = { shift };
134
+ assert.equal(companionEvents(state, inputs, at).length, 1);
135
+ state = companionTransition(
136
+ state,
137
+ {
138
+ type: 'journal',
139
+ date: shift.shiftDate,
140
+ done: 'One change',
141
+ next: 'Review',
142
+ base: 0,
143
+ end: true,
144
+ },
145
+ at,
146
+ );
147
+ assert.equal(companionEvents(state, inputs, at).length, 0);
148
+ assert.equal(state.preferences.wrapEnabled, true);
149
+ assert.equal(
150
+ companionEvents(state, { shift: { ...shift, shiftDate: '2026-11-13' } }, at).length,
151
+ 1,
152
+ );
153
+ assert.equal(companionEvents(state, { shift: { ...shift, isWorkday: false } }, at).length, 0);
154
+ });
155
+ test('notes reject a stale tab and repeating end does not duplicate history', () => {
156
+ let s = companionTransition(
157
+ defaultCompanionState(),
158
+ { type: 'journal', date: '2026-11-12', done: 'A', next: 'B', base: 0, end: true },
159
+ at,
160
+ );
161
+ assert.throws(
162
+ () =>
163
+ companionTransition(
164
+ s,
165
+ { type: 'journal', date: '2026-11-12', done: 'stale', next: '', base: 0, end: false },
166
+ at + 1,
167
+ ),
168
+ /another tab/,
169
+ );
170
+ s = companionTransition(
171
+ s,
172
+ { type: 'journal', date: '2026-11-12', done: 'A2', next: 'B', base: at, end: true },
173
+ at + 1,
174
+ );
175
+ assert.equal(s.history.length, 1);
176
+ });
177
+ test('custom timers are independent and actions reject stale per-timer revisions', () => {
178
+ let s = defaultCompanionState();
179
+ for (const id of ['tea', 'rest'])
180
+ s = companionTransition(s, { type: 'add-timer', id, name: id, minutes: 5 }, at);
181
+ s = companionTransition(
182
+ s,
183
+ { type: 'timer', id: 'tea', revision: 0, operation: { type: 'start' } },
184
+ at,
185
+ );
186
+ assert.equal(s.timers[1].clock.status, 'idle');
187
+ assert.throws(
188
+ () =>
189
+ companionTransition(
190
+ s,
191
+ { type: 'timer', id: 'tea', revision: 0, operation: { type: 'stop' } },
192
+ at,
193
+ ),
194
+ /changed/,
195
+ );
196
+ assert.equal(companionEvents(s, {}, at + 300000)[0].timerId, 'tea');
197
+ });
198
+ test('restore validates data, requires current revision, and resets permissions and leases', () => {
199
+ let s = fresh();
200
+ s.revision = 8;
201
+ const backup = fresh();
202
+ backup.preferences.sound = true;
203
+ backup.preferences.notifications = true;
204
+ backup.deliveries[event.id].owner = 'old';
205
+ backup.deliveries[event.id].leaseUntil = at + 1000;
206
+ assert.throws(
207
+ () => companionTransition(s, { type: 'restore', value: backup, base: 7 }, at),
208
+ /changed/,
209
+ );
210
+ const restored = companionTransition(s, { type: 'restore', value: backup, base: 8 }, at);
211
+ assert.equal(restored.preferences.sound, false);
212
+ assert.equal(restored.preferences.notifications, false);
213
+ assert.equal(restored.deliveries[event.id].owner, '');
214
+ assert.throws(() => validateCompanionState({ ...s, schema: 999 }));
215
+ assert.throws(() =>
216
+ validateCompanionState({
217
+ ...s,
218
+ preferences: { ...s.preferences, image: 'https://evil.example/image.svg' },
219
+ }),
220
+ );
221
+ });
222
+ test('actual event history is bounded, idempotent and counted in chosen timezone', () => {
223
+ let s = defaultCompanionState();
224
+ const e = {
225
+ id: 'drink:1',
226
+ label: 'Water',
227
+ type: 'water',
228
+ at: Date.parse('2026-11-12T23:00:00Z'),
229
+ };
230
+ s = companionTransition(s, { type: 'record', event: e }, at);
231
+ s = companionTransition(s, { type: 'record', event: e }, at);
232
+ assert.equal(s.history.length, 1);
233
+ assert.equal(companionDaySummary(s, '2026-11-13', 'Asia/Shanghai').water, 1);
234
+ assert.equal(companionDaySummary(s, '2026-11-12', 'Asia/Shanghai').water, 0);
235
+ });
236
+
237
+ test('backup preview ignores leases but detects user changes; recovery survives reopening', async () => {
238
+ const b = validateBrowserBackup({
239
+ format: 'dsh-offwork-browser',
240
+ version: 1,
241
+ createdAt: at,
242
+ companion: fresh(),
243
+ water: defaultHydration(),
244
+ smoking: defaultCountdown(),
245
+ view: {},
246
+ });
247
+ const stamp = companionBackupStamp(b);
248
+ b.water.claim = { owner: 'other-tab', until: at + 8000, sequence: 0 };
249
+ b.companion.revision++;
250
+ assert.equal(companionBackupStamp(b), stamp);
251
+ const restored = companionTransition(
252
+ b.companion,
253
+ { type: 'restore', value: b.companion, base: 0, stamp: companionStateStamp(b.companion) },
254
+ at,
255
+ );
256
+ assert.equal(restored.revision, b.companion.revision);
257
+ b.water.count++;
258
+ assert.notEqual(companionBackupStamp(b), stamp);
259
+ const idb = new IDBFactory();
260
+ let store = createCompanionRecoveryStore(idb, createStateStore);
261
+ await store.update((s) => ({ ...s, bundle: b }));
262
+ store.close();
263
+ store = createCompanionRecoveryStore(idb, createStateStore);
264
+ assert.equal((await store.update((s) => s)).bundle.water.count, 1);
265
+ store.close();
266
+ assert.throws(() => validateBrowserBackup({ ...b, water: { ...b.water, schema: 999 } }));
267
+ });
@@ -16,25 +16,25 @@ test('only a leased due reminder changes the character to a drinking pose; it ad
16
16
  assert.equal(here.announce, false);
17
17
  });
18
18
 
19
- test('explicit drink feedback is acknowledged once; quiet and break states never celebrate or animate', () => {
19
+ test('explicit drinking works outside work hours while manual quiet/pause still suppress animation', () => {
20
20
  const happy = show({ feedback: drink });
21
21
  assert.equal(happy.pose, 'happy');
22
22
  assert.equal(happy.animate, true);
23
23
  assert.equal(happy.announce, true);
24
- for (const patch of [
25
- { phase: 'quiet' },
26
- { phase: 'paused' },
27
- { workStatus: 'lunch' },
28
- { workStatus: 'rest' },
29
- { workStatus: 'done' },
30
- { workStatus: 'before' },
31
- ]) {
24
+ for (const patch of [{ phase: 'quiet' }, { phase: 'paused' }]) {
32
25
  const result = show({ ...patch, feedback: drink });
33
26
  assert.equal(result.animate, false);
34
27
  assert.equal(result.message, 'waterRecorded');
35
28
  }
29
+ for (const workStatus of ['lunch', 'rest', 'done', 'before', undefined]) {
30
+ const result = show({ workStatus, feedback: drink });
31
+ assert.equal(result.pose, 'happy');
32
+ assert.equal(result.animate, true);
33
+ const due = show({ workStatus, phase: 'due', ownsPrompt: true });
34
+ assert.equal(due.pose, 'drink');
35
+ assert.equal(due.message, null);
36
+ }
36
37
  assert.equal(show({ phase: 'quiet', feedback: drink }).pose, 'quiet');
37
- assert.equal(show({ workStatus: 'lunch', feedback: drink }).pose, 'rest');
38
38
  assert.equal(show({ feedback: { ...drink, kind: 'notice' } }).animate, false);
39
39
  });
40
40
 
@@ -71,8 +71,8 @@ test('quiet, paused and work states have stable localized messages with no autom
71
71
  assert.deepEqual(show(input), result);
72
72
  }
73
73
  assert.notEqual(show({ phase: 'quiet' }).message, show({}).message);
74
- assert.equal(show({ phase: 'paused', workStatus: 'done' }).message, 'companionDone');
75
- assert.equal(show({ phase: 'paused', workStatus: 'rest' }).message, 'companionDayOff');
74
+ assert.equal(show({ phase: 'paused', workStatus: 'done' }).message, 'companionPaused');
75
+ assert.equal(show({ phase: 'paused', workStatus: 'rest' }).message, 'companionPaused');
76
76
  });
77
77
 
78
78
  test('existing view data keeps its position, privacy and opacity while explicit companion opt-out survives reload', () => {
@@ -0,0 +1,115 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { IDBFactory } from 'fake-indexeddb';
4
+ import {
5
+ defaultCountdown,
6
+ validateCountdown,
7
+ transitionCountdown,
8
+ countdownView,
9
+ SMOKING_TIMER_DB,
10
+ } from '../countdown.js';
11
+ import { createStateStore, createHydrationStore } from '../hydration-store.js';
12
+ import { petLayout } from '../pet-view.js';
13
+ import { petUIReducer, initialPetUI } from '../pet-interactions.js';
14
+
15
+ test('wall-clock countdown stays idle until started, expires without restarting and survives serialization', () => {
16
+ const initial = defaultCountdown();
17
+ assert.equal(countdownView(initial, 8000000).phase, 'idle');
18
+ const running = transitionCountdown(initial, { type: 'start' }, 1000);
19
+ const restored = validateCountdown(JSON.parse(JSON.stringify(running)));
20
+ assert.equal(countdownView(restored, 31000).remaining, 3570000);
21
+ assert.deepEqual(countdownView(restored, 4000000), { phase: 'due', remaining: 0 });
22
+ assert.deepEqual(countdownView(restored, 5000000), { phase: 'due', remaining: 0 });
23
+ assert.equal(transitionCountdown(restored, { type: 'pause' }, 5000000).status, 'running');
24
+ assert.equal(transitionCountdown(restored, { type: 'start' }, 5000000).deadline, 8600000);
25
+ });
26
+
27
+ test('pause freezes the remainder; resume and explicit restart have different semantics', () => {
28
+ const running = transitionCountdown(defaultCountdown(), { type: 'start' }, 1000);
29
+ const paused = transitionCountdown(running, { type: 'pause' }, 31000);
30
+ assert.equal(countdownView(paused, 9000000).remaining, 3570000);
31
+ const changed = transitionCountdown(paused, { type: 'interval', minutes: 5 }, 9000000);
32
+ assert.equal(changed.remaining, paused.remaining);
33
+ const resumed = transitionCountdown(changed, { type: 'resume' }, 9000000);
34
+ assert.equal(resumed.deadline, 12570000);
35
+ assert.equal(transitionCountdown(resumed, { type: 'restart' }, 9000000).deadline, 9300000);
36
+ assert.equal(transitionCountdown(resumed, { type: 'stop' }, 9000000).status, 'idle');
37
+ assert.equal(transitionCountdown(resumed, { type: 'start' }, 9000000).deadline, resumed.deadline);
38
+ });
39
+
40
+ test('invalid or future timer data is rejected rather than resetting the countdown', () => {
41
+ for (const change of [
42
+ { schema: 2 },
43
+ { revision: -1 },
44
+ { minutes: 0 },
45
+ { minutes: 1441 },
46
+ { remaining: NaN },
47
+ { status: 'unknown' },
48
+ { status: 'paused', remaining: 0 },
49
+ { status: 'idle', deadline: 1 },
50
+ { status: 'running', deadline: 1, remaining: 2 },
51
+ ])
52
+ assert.throws(() => validateCountdown({ ...defaultCountdown(), ...change }));
53
+ assert.throws(() =>
54
+ transitionCountdown(defaultCountdown(), { type: 'interval', minutes: 2.5 }, 0),
55
+ );
56
+ assert.throws(() =>
57
+ transitionCountdown(defaultCountdown(), { type: 'start' }, Number.MAX_SAFE_INTEGER),
58
+ );
59
+ });
60
+
61
+ test('timer storage is atomic across tabs and separate from hydration records', async () => {
62
+ const idb = new IDBFactory();
63
+ const first = createStateStore(idb, SMOKING_TIMER_DB, defaultCountdown, validateCountdown);
64
+ const second = createStateStore(idb, SMOKING_TIMER_DB, defaultCountdown, validateCountdown);
65
+ const water = createHydrationStore(idb);
66
+ const original = await water.update((state) => state);
67
+ const results = await Promise.all(
68
+ [first, second].map((store) =>
69
+ store.update((state) =>
70
+ state.revision === 0 ? transitionCountdown(state, { type: 'start' }, 1000) : state,
71
+ ),
72
+ ),
73
+ );
74
+ assert.equal(results[0].revision, 1);
75
+ assert.equal(results[1].revision, 1);
76
+ const stopped = await second.update((state) =>
77
+ transitionCountdown(state, { type: 'stop' }, 2000),
78
+ );
79
+ assert.equal(stopped.revision, 2);
80
+ assert.deepEqual(await first.update((state) => state), stopped);
81
+ assert.deepEqual(await water.update((state) => state), original);
82
+ first.close();
83
+ second.close();
84
+ water.close();
85
+ });
86
+
87
+ test('independent timer cards keep navigation draft protection and reserve space below the character', () => {
88
+ for (const screen of ['water-countdown', 'smoking']) {
89
+ assert.equal(
90
+ petUIReducer(initialPetUI, { type: 'navigate', screen, root: true }).screen,
91
+ screen,
92
+ );
93
+ const dirty = { screen: 'appearance', back: null, menu: null, pending: null };
94
+ const guarded = petUIReducer(dirty, { type: 'navigate', screen, root: true, dirty: true });
95
+ assert.equal(guarded.screen, 'appearance');
96
+ assert.equal(guarded.pending.screen, screen);
97
+ }
98
+ for (const [width, height] of [
99
+ [320, 568],
100
+ [640, 360],
101
+ [390, 844],
102
+ ]) {
103
+ const layout = petLayout(
104
+ { x: 9999, y: 9999 },
105
+ { width, height },
106
+ 220,
107
+ 420,
108
+ 'whale-ornate-actions',
109
+ 90,
110
+ );
111
+ assert.ok(layout.y + layout.petHeight <= height - 12);
112
+ assert.ok(layout.x + layout.petWidth <= width - 12);
113
+ assert.ok(layout.petHeight >= layout.artworkSize * 2.1 + 90);
114
+ }
115
+ });