dsh-offwork 1.0.1 → 1.1.1

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 (41) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +38 -20
  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 +11 -0
  11. package/assets/characters/whale-actions.webp +0 -0
  12. package/assets/characters/whale-dress.webp +0 -0
  13. package/assets/characters/whale-ornate-actions.webp +0 -0
  14. package/assets/characters/whale-ornate.webp +0 -0
  15. package/client.js +4033 -188
  16. package/client.source.js +2449 -214
  17. package/companion.js +185 -0
  18. package/hydration-store.js +101 -0
  19. package/hydration.js +303 -0
  20. package/locale/en.json +1 -1
  21. package/locale/zh.json +1 -1
  22. package/package.json +13 -2
  23. package/pet-interactions.js +138 -0
  24. package/pet-motion.js +110 -0
  25. package/pet-surfaces.js +194 -0
  26. package/pet-video.js +231 -0
  27. package/pet-view.js +323 -0
  28. package/tests/companion.test.mjs +86 -0
  29. package/tests/hydration-extras.test.mjs +226 -0
  30. package/tests/hydration-store.test.mjs +287 -0
  31. package/tests/hydration.test.mjs +211 -0
  32. package/tests/pet-character.test.mjs +62 -0
  33. package/tests/pet-interactions.test.mjs +88 -0
  34. package/tests/pet-motion.test.mjs +139 -0
  35. package/tests/pet-surfaces.test.mjs +128 -0
  36. package/tests/pet-video.test.mjs +337 -0
  37. package/tests/pet-view.test.mjs +48 -0
  38. package/tests/ui.test.mjs +22 -2
  39. package/tools/build.mjs +62 -6
  40. package/tools/check-package.mjs +20 -0
  41. package/ui-utils.js +22 -4
package/pet-view.js ADDED
@@ -0,0 +1,323 @@
1
+ // Independent Harness pet presentation. No third-party pet code or assets.
2
+ export const PET_MODEL = Object.freeze({ id: 'xiaoxie-original', renderer: 'svg', license: 'MIT' });
3
+
4
+ export const PET_MODELS = Object.freeze({
5
+ 'whale-ornate-actions': {
6
+ id: 'whale-ornate-actions',
7
+ renderer: 'video',
8
+ widthScale: 1.3125,
9
+ heightScale: 2.1,
10
+ label: 'petWhaleOrnateActions',
11
+ },
12
+ 'whale-actions': {
13
+ id: 'whale-actions',
14
+ renderer: 'video',
15
+ widthScale: 1.3125,
16
+ heightScale: 2.1,
17
+ label: 'petWhaleActions',
18
+ },
19
+ 'whale-dress': {
20
+ id: 'whale-dress',
21
+ renderer: 'image',
22
+ widthScale: 1.3125,
23
+ heightScale: 2.1,
24
+ label: 'petWhaleDress',
25
+ },
26
+ 'whale-ornate': {
27
+ id: 'whale-ornate',
28
+ renderer: 'image',
29
+ widthScale: 1.3125,
30
+ heightScale: 2.1,
31
+ label: 'petWhaleOrnate',
32
+ },
33
+ 'xiaoxie-original': { ...PET_MODEL, widthScale: 1, heightScale: 1.1, label: 'petDroplet' },
34
+ });
35
+ export function petModel(id) {
36
+ return Object.hasOwn(PET_MODELS, id) ? PET_MODELS[id] : PET_MODELS['whale-dress'];
37
+ }
38
+
39
+ export function petLayout(
40
+ position,
41
+ viewport,
42
+ size = 132,
43
+ contentHeight = 420,
44
+ modelId = 'xiaoxie-original',
45
+ ) {
46
+ const model = petModel(modelId);
47
+ const originX = viewport.left ?? 0,
48
+ originY = viewport.top ?? 0;
49
+ const edge = 12,
50
+ gap = 12;
51
+ const width = Math.max(1, viewport.width),
52
+ height = Math.max(1, viewport.height);
53
+ const artworkSize = Math.max(
54
+ 1,
55
+ Math.min(
56
+ size,
57
+ Math.max(1, (width - edge * 2) / model.widthScale),
58
+ Math.max(1, Math.floor((height - edge * 2 - 30) / model.heightScale)),
59
+ ),
60
+ );
61
+ const petWidth = Math.min(
62
+ Math.max(1, width - edge * 2),
63
+ Math.max(148, artworkSize * model.widthScale),
64
+ );
65
+ const petHeight = Math.ceil(artworkSize * model.heightScale) + 30;
66
+ const bound = (n, max) => Math.max(Math.min(edge, max), Math.min(n, max));
67
+ const x = bound(
68
+ Number.isFinite(position?.x) ? position.x - originX : width - petWidth - 32,
69
+ Math.max(0, width - petWidth - edge),
70
+ );
71
+ const y = bound(
72
+ Number.isFinite(position?.y) ? position.y - originY : height - petHeight - 36,
73
+ Math.max(0, height - petHeight - edge),
74
+ );
75
+ const panelWidth = Math.min(296, Math.max(1, width - edge * 2));
76
+ let side =
77
+ x >= panelWidth + gap + edge
78
+ ? 'left'
79
+ : width - x - petWidth >= panelWidth + gap + edge
80
+ ? 'right'
81
+ : 'stack';
82
+ let maxHeight = Math.max(60, height - edge * 2);
83
+ let left = side === 'left' ? x - gap - panelWidth : x + petWidth + gap;
84
+ let top;
85
+ if (side === 'stack') {
86
+ left = Math.max(edge, Math.min(x + petWidth - panelWidth, width - panelWidth - edge));
87
+ const above = y - gap - edge;
88
+ const below = height - y - petHeight - gap - edge;
89
+ if (Math.max(above, below) < 48) {
90
+ side = 'overlay';
91
+ maxHeight = Math.max(1, height - edge * 2);
92
+ top = Math.min(edge, height / 2);
93
+ } else {
94
+ side = above >= below ? 'above' : 'below';
95
+ maxHeight = Math.max(48, side === 'above' ? above : below);
96
+ top = side === 'above' ? y - gap - Math.min(contentHeight, maxHeight) : y + petHeight + gap;
97
+ }
98
+ } else {
99
+ top = Math.max(
100
+ edge,
101
+ Math.min(y + petHeight - contentHeight, height - Math.min(contentHeight, maxHeight) - edge),
102
+ );
103
+ }
104
+ return {
105
+ x: x + originX,
106
+ y: y + originY,
107
+ petWidth,
108
+ artworkSize,
109
+ petHeight,
110
+ panel: {
111
+ left: left + originX,
112
+ top: Math.max(0, top) + originY,
113
+ width: panelWidth,
114
+ maxHeight,
115
+ side,
116
+ },
117
+ };
118
+ }
119
+
120
+ // Original full-body Xiaoxie: droplet body, small feet and a scarf.
121
+ export function petArtwork(
122
+ h,
123
+ presentation,
124
+ size,
125
+ reaction = '',
126
+ modelId = 'xiaoxie-original',
127
+ sprites = {},
128
+ interactive = false,
129
+ ) {
130
+ const { pose, animate } = presentation;
131
+ const model = petModel(modelId);
132
+ if (['image', 'video'].includes(model.renderer) && sprites[model.id]) {
133
+ if (model.renderer === 'video' && interactive)
134
+ return h(
135
+ 'span',
136
+ {
137
+ key: model.id,
138
+ className: 'owc-pet-art owc-pet-video-art',
139
+ 'aria-hidden': true,
140
+ 'data-interactive': true,
141
+ 'data-pose': pose,
142
+ 'data-animate': animate,
143
+ style: { width: size * model.widthScale, height: size * model.heightScale },
144
+ },
145
+ h('img', {
146
+ src: sprites[model.id],
147
+ alt: '',
148
+ draggable: false,
149
+ className: 'owc-pet-video-poster',
150
+ }),
151
+ h('span', { className: 'owc-pet-video-layer' }),
152
+ );
153
+ return h('img', {
154
+ key: interactive ? model.id : model.id + ':' + pose + ':' + reaction,
155
+ 'data-interactive': interactive || undefined,
156
+ src: sprites[model.id],
157
+ alt: '',
158
+ 'aria-hidden': true,
159
+ draggable: false,
160
+ width: size * model.widthScale,
161
+ height: size * model.heightScale,
162
+ className: 'owc-pet-art owc-pet-portrait',
163
+ 'data-pose': pose,
164
+ 'data-animate': animate,
165
+ });
166
+ }
167
+ const asleep = ['quiet', 'rest'].includes(pose);
168
+ const path = (d, props = {}) => h('path', { d, ...props });
169
+ return h(
170
+ 'svg',
171
+ {
172
+ key: interactive ? model.id : pose + ':' + reaction,
173
+ 'data-interactive': interactive || undefined,
174
+ viewBox: '0 0 160 176',
175
+ width: size,
176
+ height: size * 1.1,
177
+ className: 'owc-pet-art',
178
+ 'data-pose': pose,
179
+ 'data-animate': animate,
180
+ 'aria-hidden': true,
181
+ focusable: false,
182
+ },
183
+ h('ellipse', { cx: 80, cy: 160, rx: 42, ry: 6, fill: '#183b65', opacity: 0.13 }),
184
+ h(
185
+ 'g',
186
+ {
187
+ className: 'owc-pet-body',
188
+ stroke: '#315b82',
189
+ strokeWidth: 2.2,
190
+ strokeLinecap: 'round',
191
+ strokeLinejoin: 'round',
192
+ },
193
+ h('ellipse', { cx: 58, cy: 148, rx: 12, ry: 8, fill: '#92cce2' }),
194
+ h('ellipse', { cx: 102, cy: 148, rx: 12, ry: 8, fill: '#92cce2' }),
195
+ path(
196
+ pose === 'happy' ? 'M36 86Q14 91 15 65Q28 65 43 76Z' : 'M36 87Q15 87 19 109Q31 113 44 99Z',
197
+ { fill: '#a4dcea' },
198
+ ),
199
+ path(
200
+ pose === 'happy'
201
+ ? 'M124 86Q146 91 145 65Q132 65 117 76Z'
202
+ : 'M124 87Q145 87 141 109Q129 113 116 99Z',
203
+ { fill: '#a4dcea' },
204
+ ),
205
+ path(
206
+ 'M80 12C67 34 38 47 34 81C29 117 46 149 80 149C114 149 131 117 126 81C122 47 93 34 80 12Z',
207
+ { fill: '#c7eaf3' },
208
+ ),
209
+ path('M64 37Q48 53 44 69', { fill: 'none', stroke: '#fff', strokeWidth: 6, opacity: 0.8 }),
210
+ path('M46 113Q80 126 114 113L109 128Q79 137 51 127Z', { fill: '#fff1bc', stroke: '#b49454' }),
211
+ path('M102 123L113 145L100 147L92 127Z', { fill: '#ffe39b', stroke: '#b49454' }),
212
+ h('ellipse', { cx: 80, cy: 111, rx: 8, ry: 5, fill: '#e5f6fa', stroke: 'none' }),
213
+ asleep
214
+ ? path('M51 79Q59 85 67 79M93 79Q101 85 109 79', {
215
+ fill: 'none',
216
+ stroke: '#24425d',
217
+ strokeWidth: 3,
218
+ })
219
+ : h(
220
+ 'g',
221
+ { fill: '#24425d', stroke: 'none' },
222
+ h('ellipse', { cx: 59, cy: 78, rx: 3.5, ry: 5 }),
223
+ h('ellipse', { cx: 101, cy: 78, rx: 3.5, ry: 5 }),
224
+ ),
225
+ path(
226
+ pose === 'happy'
227
+ ? 'M69 91Q80 108 91 91Z'
228
+ : pose === 'offline'
229
+ ? 'M74 95H86'
230
+ : 'M72 93Q80 101 88 93',
231
+ { fill: pose === 'happy' ? '#f19baf' : 'none', stroke: '#24425d', strokeWidth: 2.5 },
232
+ ),
233
+ h(
234
+ 'g',
235
+ { fill: '#f0a2af', stroke: 'none', opacity: 0.65 },
236
+ h('ellipse', { cx: 46, cy: 91, rx: 8, ry: 4 }),
237
+ h('ellipse', { cx: 114, cy: 91, rx: 8, ry: 4 }),
238
+ ),
239
+ pose === 'drink' &&
240
+ h(
241
+ 'g',
242
+ { fill: '#fff', stroke: '#44728f' },
243
+ path('M113 103H139L135 133H117Z'),
244
+ path('M140 108H146Q154 121 138 122', { fill: 'none' }),
245
+ path('M119 115H134', { stroke: '#6dc9e2', strokeWidth: 5 }),
246
+ ),
247
+ pose === 'quiet' &&
248
+ path('M122 32H135L122 44H135', { fill: 'none', stroke: '#67899c', strokeWidth: 3 }),
249
+ ),
250
+ );
251
+ }
252
+
253
+ export const PET_MESSAGES = {
254
+ zh: {
255
+ petGreet: '与角色互动',
256
+ petCharacter: '角色形象',
257
+ petWhaleActions: '鲸蓝少女 · 长裙动态',
258
+ petWhaleOrnateActions: '鲸蓝少女 · 短裙动态',
259
+ petWhaleDress: '鲸蓝少女 · 长裙',
260
+ petWhaleOrnate: '鲸蓝少女 · 礼裙',
261
+ petDroplet: '小歇 · 水滴',
262
+ petClose: '收起宠物面板',
263
+ petQuick: '日常陪伴',
264
+ petDetails: '下班与收入',
265
+ petSize: '宠物大小',
266
+ petHint: '左键互动 · 右键菜单 · 拖动移动',
267
+ petMode: '宠物模式',
268
+ petClock: '下班倒计时',
269
+ },
270
+ en: {
271
+ petGreet: 'Interact with character',
272
+ petCharacter: 'Character',
273
+ petWhaleActions: 'Blue whale · Animated long dress',
274
+ petWhaleOrnateActions: 'Blue whale · Animated short dress',
275
+ petWhaleDress: 'Blue whale · Long dress',
276
+ petWhaleOrnate: 'Blue whale · Ornate dress',
277
+ petDroplet: 'Xiaoxie · Droplet',
278
+ petClose: 'Close pet panel',
279
+ petQuick: 'Daily companion',
280
+ petDetails: 'Clock and earnings',
281
+ petSize: 'Pet size',
282
+ petHint: 'Click to interact · right-click for menu · drag to move',
283
+ petMode: 'Pet mode',
284
+ petClock: 'Shift countdown',
285
+ },
286
+ };
287
+
288
+ export const PET_STYLE = `
289
+ .owc-v2.owc-pet-shell{border-radius:0;touch-action:none;cursor:default;overflow:visible}
290
+ .owc-pet-stage{display:flex;flex-direction:column;align-items:center;position:relative}
291
+ .owc-v2 button.owc-pet-handle{padding:0;background:none!important;border-radius:50%;min-height:0;display:block;cursor:grab;touch-action:none;line-height:0}
292
+ .owc-v2 button.owc-pet-handle:active{cursor:grabbing}
293
+ .owc-pet-art{overflow:visible;pointer-events:none;filter:drop-shadow(0 3px 5px #32557418)}
294
+ .owc-pet-video-art{display:block;position:relative}
295
+ .owc-pet-video-poster,.owc-pet-video-layer,.owc-pet-video-layer video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;pointer-events:none}
296
+ .owc-pet-video-poster{opacity:1;transition:opacity .12s linear}
297
+ .owc-pet-video-layer video{opacity:0;transition:opacity .12s linear}
298
+ .owc-pet-video-art[data-video-playing=true] .owc-pet-video-poster{opacity:0}
299
+ .owc-pet-video-art[data-video-playing=true] .owc-pet-video-layer video{opacity:1}
300
+ .owc-pet-portrait{display:block;object-fit:contain;transform-origin:50% 90%}
301
+ .owc-pet-portrait[data-animate=true]:not([data-interactive=true]){animation:owc-portrait-thanks .65s ease-in-out 2}
302
+ .owc-pet-art[data-interactive=true]{transform-origin:50% 90%}
303
+ .owc-pet-art[data-interactive=true][data-motion=idle]:not([data-action]){animation:owc-pet-breathe 4.5s ease-in-out infinite}
304
+ @keyframes owc-pet-breathe{0%,100%{transform:translateY(0) scale(1)}50%{transform:translateY(-2px) scale(1.006)}}
305
+ @keyframes owc-portrait-thanks{0%,100%{transform:translateY(0)}45%{transform:translateY(-3px) rotate(-1deg)}75%{transform:rotate(1deg)}}
306
+ .owc-pet-art[data-pose=offline]{opacity:.65}
307
+ .owc-pet-art[data-animate=true]:not([data-interactive=true]) .owc-pet-body{transform-origin:80px 150px;animation:owc-pet-thanks .65s ease-in-out 2}
308
+ @keyframes owc-pet-thanks{0%,100%{transform:translateY(0)}45%{transform:translateY(-4px) rotate(-3deg)}75%{transform:rotate(2deg)}}
309
+ .owc-v2 .owc-pet-clock{display:flex;align-items:center;color:var(--owc-text);line-height:1.5;cursor:default;padding:4px 10px;min-height:26px;gap:5px;white-space:nowrap;border-radius:999px;font-size:11px;background:var(--dsw-alias-bg-overlay);border:1px solid color-mix(in srgb,var(--owc-accent) 25%,transparent);box-shadow:0 3px 12px #142e4812;font-variant-numeric:tabular-nums;max-width:100%;box-sizing:border-box;overflow:hidden}
310
+ .owc-pet-clock-label{min-width:0;overflow:hidden;text-overflow:ellipsis}
311
+ .owc-pet-clock strong{flex:none}
312
+ .owc-pet-panel{position:fixed;border-radius:20px;cursor:default;touch-action:manipulation;max-width:calc(100vw - 24px)}
313
+ .owc-pet-panel>.owc-content{max-height:inherit}
314
+ .owc-pet-panel .owc-glass{opacity:max(var(--owc-alpha),.94)}
315
+ .owc-pet-tabs{display:flex;gap:5px;padding:8px 14px 0}
316
+ .owc-pet-tabs button{flex:1;font-size:11px}
317
+ .owc-pet-tabs button[aria-pressed=true]{color:var(--owc-accent);background:color-mix(in srgb,var(--owc-accent) 10%,transparent)}
318
+ .owc-pet-time{padding:12px 17px 0;display:flex;align-items:center;justify-content:space-between;gap:8px;color:var(--dsw-alias-label-secondary);font-size:11px}
319
+ .owc-pet-time strong{font-size:21px;color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums}
320
+ .owc-pet-speech{padding:12px 16px;font-size:12px;line-height:1.65}
321
+ .owc-pet-speech strong{display:block;font-size:11px;color:var(--dsw-alias-label-secondary);margin-bottom:4px}
322
+ @media(prefers-reduced-motion:reduce){.owc-pet-art,.owc-pet-art *{animation:none!important;transition:none!important}}
323
+ `;
@@ -0,0 +1,86 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { companionPresentation, COMPANION_MESSAGES } from '../companion.js';
4
+ import { normalizeView } from '../ui-utils.js';
5
+ const show = (patch = {}) =>
6
+ companionPresentation({ workStatus: 'working', phase: 'countdown', ...patch });
7
+ const drink = { kind: 'drink', key: 'waterRecorded', token: 12 };
8
+
9
+ test('only a leased due reminder changes the character to a drinking pose; it adds no second message', () => {
10
+ const elsewhere = show({ phase: 'due', ownsPrompt: false });
11
+ assert.equal(elsewhere.pose, 'calm');
12
+ assert.equal(elsewhere.announce, false);
13
+ const here = show({ phase: 'due', ownsPrompt: true });
14
+ assert.equal(here.pose, 'drink');
15
+ assert.equal(here.message, null);
16
+ assert.equal(here.announce, false);
17
+ });
18
+
19
+ test('explicit drink feedback is acknowledged once; quiet and break states never celebrate or animate', () => {
20
+ const happy = show({ feedback: drink });
21
+ assert.equal(happy.pose, 'happy');
22
+ assert.equal(happy.animate, true);
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
+ ]) {
32
+ const result = show({ ...patch, feedback: drink });
33
+ assert.equal(result.animate, false);
34
+ assert.equal(result.message, 'waterRecorded');
35
+ }
36
+ assert.equal(show({ phase: 'quiet', feedback: drink }).pose, 'quiet');
37
+ assert.equal(show({ workStatus: 'lunch', feedback: drink }).pose, 'rest');
38
+ assert.equal(show({ feedback: { ...drink, kind: 'notice' } }).animate, false);
39
+ });
40
+
41
+ test('hidden character, hidden page, reduced animation preference and unavailable state suppress reactions', () => {
42
+ const hidden = show({ visible: false, feedback: drink });
43
+ assert.equal(hidden.visible, false);
44
+ assert.equal(hidden.message, null);
45
+ assert.equal(hidden.announce, false);
46
+ assert.equal(show({ pageVisible: false, feedback: drink }).animate, false);
47
+ assert.equal(show({ motion: false, feedback: drink }).animate, false);
48
+ const failed = show({ failed: true, feedback: drink });
49
+ assert.equal(failed.message, 'companionUnavailable');
50
+ assert.equal(failed.announce, false);
51
+ assert.equal(failed.animate, false);
52
+ assert.equal(show({ ready: false, feedback: drink }).message, 'companionLoading');
53
+ });
54
+
55
+ test('quiet, paused and work states have stable localized messages with no automatic announcements', () => {
56
+ const cases = [
57
+ { phase: 'quiet' },
58
+ { phase: 'paused' },
59
+ { workStatus: 'lunch' },
60
+ { workStatus: 'rest' },
61
+ { workStatus: 'done' },
62
+ { workStatus: 'before' },
63
+ { phase: 'off' },
64
+ {},
65
+ ];
66
+ for (const input of cases) {
67
+ const result = show(input);
68
+ assert.equal(result.announce, false);
69
+ assert.equal(result.animate, false);
70
+ for (const lang of ['zh', 'en']) assert.ok(COMPANION_MESSAGES[lang][result.message]);
71
+ assert.deepEqual(show(input), result);
72
+ }
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');
76
+ });
77
+
78
+ test('existing view data keeps its position, privacy and opacity while explicit companion opt-out survives reload', () => {
79
+ const old = { mini: false, masked: true, opacity: 0.6, position: { x: 34, y: 56 } };
80
+ const migrated = normalizeView(old);
81
+ for (const key of Object.keys(old)) assert.deepEqual(migrated[key], old[key]);
82
+ assert.equal(migrated.companion, true);
83
+ const off = { ...migrated, companion: false, companionMotion: false };
84
+ assert.deepEqual(normalizeView(JSON.parse(JSON.stringify(off))), off);
85
+ assert.equal(normalizeView({ ...off, companion: true }).companionMotion, false);
86
+ });
@@ -0,0 +1,226 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { calculate } from '../clock.js';
4
+ import {
5
+ defaultHydration,
6
+ validateHydration,
7
+ hydrationContext,
8
+ hydrationView,
9
+ transitionHydration,
10
+ HYDRATION_ACTION_LIMIT,
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 time = (text) => Date.parse(text.includes('T') ? text : `2026-11-04T${text}:00+08:00`);
25
+ const context = (text, patch = {}) => {
26
+ const config = { ...settings, ...patch };
27
+ return hydrationContext(config, calculate(config, time(text)));
28
+ };
29
+ const act = (s, at, a, patch = {}, owner = 'a') =>
30
+ transitionHydration(s, context(at, patch), a, time(at), owner);
31
+ const view = (s, at, patch = {}) => hydrationView(s, context(at, patch), time(at));
32
+ const enabled = (at = '09:00', interval = 60, patch = {}) =>
33
+ act(defaultHydration(), at, { type: 'preferences', enabled: true, interval }, patch);
34
+ const manual = (at, id, patch = {}, extra = {}) => ({
35
+ type: 'manual-drink',
36
+ actionId: id,
37
+ shiftId: context(at, patch).id,
38
+ ...extra,
39
+ });
40
+
41
+ test('manual check-in restarts an enabled timer and retries are idempotent', () => {
42
+ const initial = enabled();
43
+ const action = manual('09:40', 'glass-1');
44
+ const next = act(initial, '09:40', action);
45
+ assert.equal(next.shift.count, 1);
46
+ assert.equal(view(next, '09:40').remaining, 3600000);
47
+ assert.deepEqual(act(next, '09:45', action), next);
48
+ assert.equal(initial.shift.count, 0);
49
+ });
50
+
51
+ test('manual check-ins work with reminders off, preserve pause, and ignore off-hours', () => {
52
+ let state = act(defaultHydration(), '10:00', manual('10:00', 'off'));
53
+ assert.equal(state.enabled, false);
54
+ assert.equal(state.shift.count, 1);
55
+ state = act(state, '10:00', { type: 'preferences', enabled: true });
56
+ state = act(state, '10:10', { type: 'pause' });
57
+ const due = state.shift.due;
58
+ state = act(state, '10:20', manual('10:20', 'paused'));
59
+ assert.equal(state.shift.count, 2);
60
+ assert.equal(state.shift.paused, true);
61
+ assert.equal(state.shift.due, due);
62
+ for (const at of ['08:00', '18:00', '2026-11-07T10:00:00+08:00']) {
63
+ const result = act(defaultHydration(), at, manual(at, at));
64
+ assert.equal(result.shift, null);
65
+ }
66
+ });
67
+
68
+ test('lunch check-in starts a full working interval after lunch', () => {
69
+ const state = act(enabled('11:00', 5), '12:30', manual('12:30', 'lunch'));
70
+ assert.equal(state.shift.count, 1);
71
+ assert.equal(view(state, '12:59').phase, 'break');
72
+ assert.equal(view(state, '13:00').remaining, 300000);
73
+ assert.equal(view(state, '13:05').phase, 'due');
74
+ });
75
+
76
+ test('manual and prompted confirmation of the same due event count once in either order', () => {
77
+ const initial = act(enabled(), '10:00', { type: 'claim' });
78
+ const eventId = view(initial, '10:00').eventId;
79
+ const actions = [manual('10:00', 'due', {}, { eventId }), { type: 'drink', eventId }];
80
+ for (const order of [actions, [...actions].reverse()]) {
81
+ let state = initial;
82
+ for (const action of order) state = act(state, '10:00', action);
83
+ assert.equal(state.shift.count, 1);
84
+ assert.equal(state.shift.claim, null);
85
+ }
86
+ });
87
+
88
+ test('manual request IDs are bounded, distinct records count, and old-shift retries cannot count tomorrow', () => {
89
+ let state = enabled();
90
+ for (let i = 0; i < HYDRATION_ACTION_LIMIT + 3; i++)
91
+ state = act(state, '09:10', manual('09:10', String(i)));
92
+ assert.equal(state.shift.count, HYDRATION_ACTION_LIMIT + 3);
93
+ assert.equal(state.shift.recentActions.length, HYDRATION_ACTION_LIMIT);
94
+ assert.equal(validateHydration(state).shift.count, state.shift.count);
95
+ const tomorrow = act(state, '2026-11-05T09:10:00+08:00', manual('09:10', 'old'));
96
+ assert.equal(tomorrow.shift.count, 0);
97
+ });
98
+
99
+ test('quiet time suppresses a due prompt, keeps its work deadline, and resumes once at expiry', () => {
100
+ let state = act(enabled('09:00', 5), '09:05', { type: 'claim' });
101
+ const oldEvent = view(state, '09:05').eventId;
102
+ const due = state.shift.due;
103
+ state = act(state, '09:05', { type: 'quiet', minutes: 30 });
104
+ assert.equal(state.shift.due, due);
105
+ assert.equal(state.shift.claim, null);
106
+ assert.equal(view(state, '09:34').phase, 'quiet');
107
+ assert.equal(view(state, '09:34').quietRemaining, 60000);
108
+ assert.equal(act(state, '09:10', { type: 'claim' }).shift.claim, null);
109
+ assert.equal(act(state, '09:10', { type: 'drink', eventId: oldEvent }).shift.count, 0);
110
+ assert.equal(view(state, '09:35').phase, 'due');
111
+ const resumed = act(state, '09:35', { type: 'claim' });
112
+ assert.equal(resumed.shift.quietUntil, 0);
113
+ assert.equal(resumed.shift.claim.owner, 'a');
114
+ assert.equal(view(resumed, '10:30').eventId, view(resumed, '09:35').eventId);
115
+ });
116
+
117
+ test('quiet time uses natural minutes across lunch and never prompts during lunch', () => {
118
+ let state = act(enabled('11:00', 5), '11:50', { type: 'quiet', minutes: 30 });
119
+ assert.equal(view(state, '12:10').quietRemaining, 600000);
120
+ assert.equal(view(state, '12:20').phase, 'break');
121
+ assert.equal(view(state, '13:00').phase, 'due');
122
+ state = act(state, '12:30', manual('12:30', 'lunch-quiet'));
123
+ assert.equal(view(state, '13:00').remaining, 300000);
124
+ });
125
+
126
+ test('manual and quiet actions commute and a check-in does not end quiet time', () => {
127
+ const actions = [manual('09:10', 'concurrent'), { type: 'quiet', minutes: 30 }];
128
+ for (const order of [actions, [...actions].reverse()]) {
129
+ let state = enabled('09:00', 5);
130
+ for (const a of order) state = act(state, '09:10', a);
131
+ assert.equal(state.shift.count, 1);
132
+ assert.equal(state.shift.quietUntil, time('09:40'));
133
+ assert.equal(state.shift.due, context('09:15').elapsed);
134
+ assert.equal(view(state, '09:14').phase, 'quiet');
135
+ }
136
+ });
137
+
138
+ test('quiet duration replaces instead of adding, and can be ended early', () => {
139
+ let state = act(enabled(), '09:05', { type: 'quiet', minutes: 30 });
140
+ state = act(state, '09:10', { type: 'quiet', minutes: 60 });
141
+ assert.equal(state.shift.quietUntil, time('10:10'));
142
+ state = act(state, '09:20', { type: 'end-quiet' });
143
+ assert.equal(view(state, '09:20').phase, 'countdown');
144
+ assert.equal(state.shift.due, 3600000);
145
+ for (const minutes of [0, 10, 31, 90])
146
+ assert.throws(() => act(enabled(), '09:10', { type: 'quiet', minutes }));
147
+ });
148
+
149
+ test('pause and disable clear quiet time, expiry never cancels a full-shift pause', () => {
150
+ const quiet = act(enabled(), '09:05', { type: 'quiet', minutes: 60 });
151
+ const paused = act(quiet, '09:10', { type: 'pause' });
152
+ assert.equal(paused.shift.quietUntil, 0);
153
+ assert.equal(view(paused, '11:00').phase, 'paused');
154
+ assert.equal(act(paused, '09:15', { type: 'quiet', minutes: 30 }).shift.quietUntil, 0);
155
+ const off = act(quiet, '09:10', { type: 'preferences', enabled: false });
156
+ assert.equal(off.shift.quietUntil, 0);
157
+ const resumed = act(paused, '09:20', { type: 'resume' });
158
+ assert.equal(view(resumed, '09:20').remaining, 3600000);
159
+ });
160
+
161
+ test('quiet mode ends at shift end and does not leak into another shift, including overnight', () => {
162
+ let state = act(enabled('17:00', 5), '17:50', { type: 'quiet', minutes: 60 });
163
+ assert.equal(state.shift.quietUntil, time('18:00'));
164
+ assert.equal(view(state, '18:00').phase, 'waiting');
165
+ state = act(state, '2026-11-05T09:00:00+08:00', { type: 'tick' });
166
+ assert.equal(state.shift.quietUntil, 0);
167
+ const night = { start: '22:00', end: '06:00', breakStart: '02:00', breakEnd: '03:00' };
168
+ let overnight = enabled('2026-11-04T22:00:00+08:00', 5, night);
169
+ overnight = act(overnight, '2026-11-04T23:50:00+08:00', { type: 'quiet', minutes: 30 }, night);
170
+ assert.equal(view(overnight, '2026-11-05T00:10:00+08:00', night).quietRemaining, 600000);
171
+ assert.equal(view(overnight, '2026-11-05T00:20:00+08:00', night).phase, 'due');
172
+ });
173
+
174
+ test('same-shift schedule edits preserve quiet intent, clamp to new end and reject stale manual changes', () => {
175
+ const state = act(enabled('17:00', 5), '17:05', { type: 'quiet', minutes: 30 });
176
+ const patch = { end: '17:20' };
177
+ const updated = act(state, '17:10', { type: 'tick', baseSchedule: state.shift.schedule }, patch);
178
+ assert.equal(updated.shift.quietUntil, time('17:20'));
179
+ assert.deepEqual(act(updated, '17:11', manual('17:11', 'stale')), updated);
180
+ assert.deepEqual(act(updated, '17:11', { type: 'quiet', minutes: 60 }), updated);
181
+ });
182
+
183
+ test('schema 1 migrates with preferences, count, pause and due intact while revoking the lease', () => {
184
+ const legacy = act(enabled('09:00', 5), '09:05', { type: 'claim' });
185
+ legacy.schema = 1;
186
+ legacy.shift.count = 7;
187
+ legacy.shift.paused = true;
188
+ delete legacy.shift.quietUntil;
189
+ delete legacy.shift.recentActions;
190
+ const upgraded = validateHydration(legacy);
191
+ assert.equal(upgraded.schema, 2);
192
+ assert.equal(upgraded.interval, 5);
193
+ assert.equal(upgraded.shift.count, 7);
194
+ assert.equal(upgraded.shift.paused, true);
195
+ assert.equal(upgraded.shift.due, legacy.shift.due);
196
+ assert.equal(upgraded.shift.claim, null);
197
+ assert.equal(upgraded.shift.sequence, legacy.shift.sequence + 1);
198
+ assert.equal(upgraded.shift.quietUntil, 0);
199
+ assert.deepEqual(upgraded.shift.recentActions, []);
200
+ assert.deepEqual(validateHydration(upgraded), upgraded);
201
+ });
202
+
203
+ test('stale pause and resume actions do not postpone a timer or pause disabled reminders', () => {
204
+ let state = act(enabled(), '09:10', { type: 'pause' });
205
+ state = act(state, '09:20', { type: 'resume' });
206
+ assert.deepEqual(act(state, '09:30', { type: 'resume' }), state);
207
+ state = act(state, '09:30', { type: 'preferences', enabled: false });
208
+ assert.deepEqual(act(state, '09:40', { type: 'pause' }), state);
209
+ });
210
+
211
+ test('malformed new fields and malformed legacy data are rejected', () => {
212
+ for (const patch of [
213
+ { quietUntil: -1 },
214
+ { quietUntil: NaN },
215
+ { recentActions: null },
216
+ { recentActions: ['same', 'same'] },
217
+ { recentActions: Array.from({ length: 65 }, (_, i) => String(i)) },
218
+ ]) {
219
+ const state = enabled();
220
+ Object.assign(state.shift, patch);
221
+ assert.throws(() => validateHydration(state));
222
+ }
223
+ const legacy = enabled();
224
+ legacy.schema = 1;
225
+ assert.throws(() => validateHydration(legacy));
226
+ });