dsh-offwork 1.0.1 → 1.1.0
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.
- package/CHANGELOG.md +10 -0
- package/README.md +35 -20
- package/assets/characters/NOTICE.md +7 -0
- package/assets/characters/whale-dress.webp +0 -0
- package/assets/characters/whale-ornate.webp +0 -0
- package/client.js +3569 -188
- package/client.source.js +2389 -218
- package/companion.js +185 -0
- package/hydration-store.js +101 -0
- package/hydration.js +303 -0
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +10 -2
- package/pet-interactions.js +138 -0
- package/pet-surfaces.js +194 -0
- package/pet-view.js +272 -0
- package/tests/companion.test.mjs +86 -0
- package/tests/hydration-extras.test.mjs +226 -0
- package/tests/hydration-store.test.mjs +287 -0
- package/tests/hydration.test.mjs +211 -0
- package/tests/pet-character.test.mjs +56 -0
- package/tests/pet-interactions.test.mjs +88 -0
- package/tests/pet-surfaces.test.mjs +128 -0
- package/tests/pet-view.test.mjs +48 -0
- package/tests/ui.test.mjs +22 -2
- package/tools/build.mjs +36 -6
- package/tools/check-package.mjs +9 -0
- package/ui-utils.js +16 -4
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { IDBFactory } from 'fake-indexeddb';
|
|
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);
|
|
31
|
+
const fixture = (t) => {
|
|
32
|
+
const indexedDB = new IDBFactory();
|
|
33
|
+
const first = createHydrationStore(indexedDB);
|
|
34
|
+
const second = createHydrationStore(indexedDB);
|
|
35
|
+
t.after(() => {
|
|
36
|
+
first.close();
|
|
37
|
+
second.close();
|
|
38
|
+
});
|
|
39
|
+
return { indexedDB, first, second };
|
|
40
|
+
};
|
|
41
|
+
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
|
+
),
|
|
50
|
+
);
|
|
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);
|
|
72
|
+
assert.deepEqual(await read(first), defaultHydration());
|
|
73
|
+
const state = await seed(first);
|
|
74
|
+
first.close();
|
|
75
|
+
const reopened = createHydrationStore(indexedDB);
|
|
76
|
+
t.after(() => reopened.close());
|
|
77
|
+
assert.deepEqual(await read(reopened), state);
|
|
78
|
+
assert.ok(state.revision > 0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('transactions serialize competing claims and duplicate processing across connections', async (t) => {
|
|
82
|
+
const { first, second } = fixture(t);
|
|
83
|
+
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
|
+
await Promise.all(
|
|
92
|
+
[first, second].map((store) =>
|
|
93
|
+
store.update((s) =>
|
|
94
|
+
transitionHydration(s, context(due), { type: 'drink', eventId }, due, owner),
|
|
95
|
+
),
|
|
96
|
+
),
|
|
97
|
+
);
|
|
98
|
+
assert.equal((await read(first)).shift.count, 1);
|
|
99
|
+
assert.deepEqual(await read(first), await read(second));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('stale tabs cannot revert a changed schedule with tick, release, pause, or clear actions', async (t) => {
|
|
103
|
+
const { first, second } = fixture(t);
|
|
104
|
+
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);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('a tab without Host settings cannot erase another tab claim', async (t) => {
|
|
135
|
+
const { first, second } = fixture(t);
|
|
136
|
+
await seed(first);
|
|
137
|
+
const claimed = await first.update((s) =>
|
|
138
|
+
transitionHydration(s, context(due), { type: 'claim' }, due, 'a'),
|
|
139
|
+
);
|
|
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);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('explicit reset is monotonic and unknown data is rejected without overwriting', async (t) => {
|
|
147
|
+
const { indexedDB, first, second } = fixture(t);
|
|
148
|
+
await seed(first);
|
|
149
|
+
await rawWrite(indexedDB, { schema: 99, revision: 40 });
|
|
150
|
+
await assert.rejects(read(second), /Invalid hydration data/);
|
|
151
|
+
const reset = await first.update(() => defaultHydration(), { reset: true });
|
|
152
|
+
assert.equal(reset.revision, 41);
|
|
153
|
+
assert.equal(reset.enabled, false);
|
|
154
|
+
assert.equal(reset.shift, null);
|
|
155
|
+
assert.deepEqual(await read(second), reset);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('mutating reducers and aborting reducers cannot silently lose or partially commit updates', async (t) => {
|
|
159
|
+
const { first } = fixture(t);
|
|
160
|
+
await seed(first);
|
|
161
|
+
const state = await first.update((s) => {
|
|
162
|
+
s.interval = 30;
|
|
163
|
+
return s;
|
|
164
|
+
});
|
|
165
|
+
assert.equal((await read(first)).interval, 30);
|
|
166
|
+
await assert.rejects(
|
|
167
|
+
first.update((s) => {
|
|
168
|
+
s.interval = 10;
|
|
169
|
+
throw new Error('abort');
|
|
170
|
+
}),
|
|
171
|
+
/abort/,
|
|
172
|
+
);
|
|
173
|
+
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'),
|
|
184
|
+
);
|
|
185
|
+
const state = await read(first);
|
|
186
|
+
assert.equal(state.interval, 30);
|
|
187
|
+
assert.equal(state.enabled, false);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('transient opening errors recover and unavailable storage rejects explicitly', async () => {
|
|
191
|
+
let calls = 0;
|
|
192
|
+
const indexedDB = new IDBFactory();
|
|
193
|
+
const store = createHydrationStore({
|
|
194
|
+
open(...args) {
|
|
195
|
+
if (++calls === 1) throw new Error('temporary');
|
|
196
|
+
return indexedDB.open(...args);
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
await assert.rejects(read(store), /temporary/);
|
|
200
|
+
assert.deepEqual(await read(store), defaultHydration());
|
|
201
|
+
assert.equal(calls, 2);
|
|
202
|
+
store.close();
|
|
203
|
+
const unavailable = createHydrationStore(null);
|
|
204
|
+
await assert.rejects(read(unavailable), /unavailable/);
|
|
205
|
+
unavailable.close();
|
|
206
|
+
});
|
|
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);
|
|
251
|
+
});
|
|
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);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test('quiet and manual actions preserve each other across connections', async (t) => {
|
|
266
|
+
const { first, second } = fixture(t);
|
|
267
|
+
await seed(first);
|
|
268
|
+
await Promise.all([
|
|
269
|
+
first.update((s) =>
|
|
270
|
+
transitionHydration(s, context(begin), { type: 'quiet', minutes: 30 }, begin, 'a'),
|
|
271
|
+
),
|
|
272
|
+
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
|
+
),
|
|
280
|
+
),
|
|
281
|
+
]);
|
|
282
|
+
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);
|
|
287
|
+
});
|