dsh-offwork 1.0.0 → 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 +27 -0
- package/README.md +77 -43
- 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 +4410 -327
- package/client.source.js +3093 -278
- package/clock.js +81 -34
- package/companion.js +185 -0
- package/hydration-store.js +101 -0
- package/hydration.js +303 -0
- package/index.js +142 -66
- package/locale/en.json +6 -1
- package/locale/zh.json +6 -1
- package/package.json +27 -6
- package/pet-interactions.js +138 -0
- package/pet-surfaces.js +194 -0
- package/pet-view.js +272 -0
- package/tests/clock.test.mjs +76 -33
- package/tests/companion.test.mjs +86 -0
- package/tests/host.test.mjs +76 -27
- 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 +98 -21
- package/tools/build.mjs +52 -7
- package/tools/check-package.mjs +105 -0
- package/ui-utils.js +37 -14
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
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
|
+
reconcileHydration,
|
|
9
|
+
hydrationView,
|
|
10
|
+
transitionHydration,
|
|
11
|
+
HYDRATION_LEASE_MS,
|
|
12
|
+
} from '../hydration.js';
|
|
13
|
+
|
|
14
|
+
const settings = {
|
|
15
|
+
payMode: 'monthly',
|
|
16
|
+
monthlySalary: 12000,
|
|
17
|
+
dailySalary: 600,
|
|
18
|
+
workdaysPerMonth: 20,
|
|
19
|
+
start: '09:00',
|
|
20
|
+
end: '18:00',
|
|
21
|
+
breakStart: '12:00',
|
|
22
|
+
breakEnd: '13:00',
|
|
23
|
+
weekdays: [1, 2, 3, 4, 5],
|
|
24
|
+
timezone: 'Asia/Shanghai',
|
|
25
|
+
};
|
|
26
|
+
const time = (text) => Date.parse(text.includes('T') ? text : `2026-11-04T${text}:00+08:00`);
|
|
27
|
+
const context = (text, patch = {}) => {
|
|
28
|
+
const config = { ...settings, ...patch };
|
|
29
|
+
return hydrationContext(config, calculate(config, time(text)));
|
|
30
|
+
};
|
|
31
|
+
const act = (state, at, action, patch = {}, owner = 'tab-a') =>
|
|
32
|
+
transitionHydration(state, context(at, patch), action, time(at), owner);
|
|
33
|
+
const enable = (at = '09:00', interval = 60, patch = {}) =>
|
|
34
|
+
act(defaultHydration(), at, { type: 'preferences', enabled: true, interval }, patch);
|
|
35
|
+
const view = (state, at, patch = {}) =>
|
|
36
|
+
hydrationView(reconcileHydration(state, context(at, patch)), context(at, patch), time(at));
|
|
37
|
+
const claim = (state, at, patch = {}, owner = 'tab-a') =>
|
|
38
|
+
act(state, at, { type: 'claim' }, patch, owner);
|
|
39
|
+
|
|
40
|
+
test('off by default; enabling mid-shift starts a full interval, not catch-up', () => {
|
|
41
|
+
assert.equal(view(defaultHydration(), '11:45').phase, 'off');
|
|
42
|
+
const state = enable('10:15');
|
|
43
|
+
assert.equal(view(state, '10:15').remaining, 3600000);
|
|
44
|
+
assert.equal(view(state, '11:14').phase, 'countdown');
|
|
45
|
+
assert.equal(view(state, '11:15').phase, 'due');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
for (const interval of [5, 15, 17, 30, 45, 60, 90, 120, 240]) {
|
|
49
|
+
test(`saving ${interval} minutes applies that interval instead of the 60-minute default`, () => {
|
|
50
|
+
const initial = enable('09:00');
|
|
51
|
+
const saved = act(initial, '09:10', { type: 'preferences', enabled: true, interval });
|
|
52
|
+
assert.equal(saved.interval, interval);
|
|
53
|
+
const elapsed = context('09:10').elapsed;
|
|
54
|
+
// Saving an unchanged 60-minute interval keeps the existing cycle.
|
|
55
|
+
const anchor = interval === 60 ? 0 : elapsed;
|
|
56
|
+
assert.equal(saved.shift.due, anchor + interval * 60000);
|
|
57
|
+
const reopened = validateHydration(JSON.parse(JSON.stringify(saved)));
|
|
58
|
+
assert.equal(reopened.interval, interval);
|
|
59
|
+
const deadline =
|
|
60
|
+
time('09:00') + saved.shift.due + (saved.shift.due >= 3 * 3600000 ? 3600000 : 0);
|
|
61
|
+
const before = hydrationContext(settings, calculate(settings, deadline - 1));
|
|
62
|
+
const atDue = hydrationContext(settings, calculate(settings, deadline));
|
|
63
|
+
assert.equal(hydrationView(reopened, before).phase, 'countdown');
|
|
64
|
+
assert.equal(hydrationView(reopened, atDue).phase, 'due');
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
test('break freezes working-time countdown; snooze crosses break', () => {
|
|
69
|
+
const state = enable('11:10');
|
|
70
|
+
assert.equal(view(state, '12:00').phase, 'break');
|
|
71
|
+
assert.equal(view(state, '12:50').remaining, 600000);
|
|
72
|
+
assert.equal(view(state, '13:10').phase, 'due');
|
|
73
|
+
let snoozed = claim(enable('10:58'), '11:58');
|
|
74
|
+
snoozed = act(snoozed, '11:58', { type: 'snooze', eventId: view(snoozed, '11:58').eventId });
|
|
75
|
+
assert.equal(view(snoozed, '12:30').remaining, 480000);
|
|
76
|
+
assert.equal(view(snoozed, '13:07').phase, 'countdown');
|
|
77
|
+
assert.equal(view(snoozed, '13:08').phase, 'due');
|
|
78
|
+
assert.equal(snoozed.shift.count, 0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('completion is idempotent and schedules from actual click, not old due time', () => {
|
|
82
|
+
let state = claim(enable(), '10:03');
|
|
83
|
+
const eventId = view(state, '10:03').eventId;
|
|
84
|
+
state = act(state, '10:03', { type: 'drink', eventId });
|
|
85
|
+
state = act(state, '10:03', { type: 'drink', eventId });
|
|
86
|
+
assert.equal(state.shift.count, 1);
|
|
87
|
+
assert.equal(view(state, '11:02').phase, 'countdown');
|
|
88
|
+
assert.equal(view(state, '11:03').phase, 'due');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('hours of missed ticks yield a single stable event; shift end discards it', () => {
|
|
92
|
+
const state = enable();
|
|
93
|
+
assert.equal(view(state, '10:00').eventId, view(state, '17:30').eventId);
|
|
94
|
+
assert.equal(view(state, '17:30').phase, 'due');
|
|
95
|
+
assert.equal(view(state, '18:00').phase, 'waiting');
|
|
96
|
+
assert.equal(view(state, '2026-11-07T11:00:00+08:00').phase, 'waiting');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('pause persists through overnight midnight and resets at a new shift', () => {
|
|
100
|
+
const night = { start: '22:00', end: '06:00', breakStart: '02:00', breakEnd: '03:00' };
|
|
101
|
+
let state = enable('2026-11-06T22:00:00+08:00', 60, night);
|
|
102
|
+
state = act(state, '2026-11-06T23:00:00+08:00', { type: 'pause' }, night);
|
|
103
|
+
assert.equal(view(state, '2026-11-07T01:00:00+08:00', night).phase, 'paused');
|
|
104
|
+
assert.equal(view(state, '2026-11-07T22:00:00+08:00', night).phase, 'waiting');
|
|
105
|
+
const next = reconcileHydration(state, context('2026-11-09T22:00:00+08:00', night));
|
|
106
|
+
assert.equal(hydrationView(next, context('2026-11-09T22:00:00+08:00', night)).phase, 'countdown');
|
|
107
|
+
assert.equal(next.shift.paused, false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('resume and interval changes restart; count reset does not alter timer', () => {
|
|
111
|
+
let state = claim(enable(), '10:00');
|
|
112
|
+
state = act(state, '10:00', { type: 'drink', eventId: view(state, '10:00').eventId });
|
|
113
|
+
const due = state.shift.due;
|
|
114
|
+
state = act(state, '10:10', { type: 'clear-count' });
|
|
115
|
+
assert.equal(state.shift.count, 0);
|
|
116
|
+
assert.equal(state.shift.due, due);
|
|
117
|
+
state = act(state, '10:10', { type: 'pause' });
|
|
118
|
+
state = act(state, '10:40', { type: 'resume' });
|
|
119
|
+
assert.equal(view(state, '10:40').remaining, 3600000);
|
|
120
|
+
state = act(state, '10:50', { type: 'preferences', enabled: true, interval: 5 });
|
|
121
|
+
assert.equal(view(state, '10:50').remaining, 300000);
|
|
122
|
+
assert.equal(view(state, '10:55').phase, 'due');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('schedule changes retain same-shift count and pause; pay changes do not reschedule', () => {
|
|
126
|
+
let state = claim(enable(), '10:00');
|
|
127
|
+
state = act(state, '10:00', { type: 'drink', eventId: view(state, '10:00').eventId });
|
|
128
|
+
const oldDue = state.shift.due;
|
|
129
|
+
assert.equal(
|
|
130
|
+
reconcileHydration(state, context('10:30', { monthlySalary: 15000 })).shift.due,
|
|
131
|
+
oldDue,
|
|
132
|
+
);
|
|
133
|
+
state = act(state, '10:30', { type: 'pause' });
|
|
134
|
+
const shifted = reconcileHydration(state, context('10:45', { end: '19:00' }));
|
|
135
|
+
assert.equal(shifted.shift.count, 1);
|
|
136
|
+
assert.equal(shifted.shift.paused, true);
|
|
137
|
+
assert.equal(shifted.shift.due - context('10:45', { end: '19:00' }).elapsed, 3600000);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('only the current lease owner can process a live event; expired lease is transferred', () => {
|
|
141
|
+
let state = claim(enable(), '10:00');
|
|
142
|
+
const eventId = view(state, '10:00').eventId;
|
|
143
|
+
state = claim(state, '10:00', {}, 'tab-b');
|
|
144
|
+
assert.equal(state.shift.claim.owner, 'tab-a');
|
|
145
|
+
assert.equal(act(state, '10:00', { type: 'drink', eventId }, {}, 'tab-b').shift.count, 0);
|
|
146
|
+
const at = time('10:00') + HYDRATION_LEASE_MS + 1;
|
|
147
|
+
const ctx = hydrationContext(settings, calculate(settings, at));
|
|
148
|
+
state = transitionHydration(state, ctx, { type: 'claim' }, at, 'tab-b');
|
|
149
|
+
assert.equal(state.shift.claim.owner, 'tab-b');
|
|
150
|
+
assert.equal(
|
|
151
|
+
transitionHydration(state, ctx, { type: 'drink', eventId }, at, 'tab-a').shift.count,
|
|
152
|
+
0,
|
|
153
|
+
);
|
|
154
|
+
assert.equal(
|
|
155
|
+
transitionHydration(state, ctx, { type: 'drink', eventId }, at, 'tab-b').shift.count,
|
|
156
|
+
1,
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('release, disabled settings and break boundaries remove a displayed lease', () => {
|
|
161
|
+
let state = claim(enable(), '10:00');
|
|
162
|
+
assert.equal(act(state, '10:00', { type: 'release' }).shift.claim, null);
|
|
163
|
+
assert.equal(
|
|
164
|
+
act(state, '10:00', { type: 'preferences', enabled: false, interval: 60 }).shift.claim,
|
|
165
|
+
null,
|
|
166
|
+
);
|
|
167
|
+
state = claim(enable('11:00'), '11:59');
|
|
168
|
+
assert.equal(reconcileHydration(state, context('12:00')).shift.claim, null);
|
|
169
|
+
assert.deepEqual(act(state, '12:00', { type: 'reset' }), defaultHydration());
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('at exact shift end there is no reminder; first opening tomorrow starts fresh', () => {
|
|
173
|
+
const state = enable('17:00');
|
|
174
|
+
assert.equal(view(state, '17:00').phase, 'finished');
|
|
175
|
+
assert.notEqual(view(state, '18:00').phase, 'due');
|
|
176
|
+
assert.equal(view(state, '2026-11-05T10:25:00+08:00').remaining, 3600000);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('DST forward and backward transitions count actual paid elapsed time', () => {
|
|
180
|
+
const patch = {
|
|
181
|
+
timezone: 'America/New_York',
|
|
182
|
+
start: '22:00',
|
|
183
|
+
end: '06:00',
|
|
184
|
+
breakStart: '00:00',
|
|
185
|
+
breakEnd: '00:00',
|
|
186
|
+
weekdays: [6],
|
|
187
|
+
};
|
|
188
|
+
const spring = enable('2026-03-08T06:30:00Z', 60, patch);
|
|
189
|
+
assert.equal(view(spring, '2026-03-08T07:00:00Z', patch).remaining, 1800000);
|
|
190
|
+
assert.equal(view(spring, '2026-03-08T07:30:00Z', patch).phase, 'due');
|
|
191
|
+
const fall = enable('2026-11-01T05:30:00Z', 60, patch);
|
|
192
|
+
assert.equal(view(fall, '2026-11-01T06:30:00Z', patch).phase, 'due');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('malformed data, unknown schemas and invalid preferences fail closed', () => {
|
|
196
|
+
for (const data of [
|
|
197
|
+
null,
|
|
198
|
+
{},
|
|
199
|
+
[],
|
|
200
|
+
{ ...defaultHydration(), schema: 99 },
|
|
201
|
+
,
|
|
202
|
+
{ ...defaultHydration(), interval: 0 },
|
|
203
|
+
{ ...defaultHydration(), shift: {} },
|
|
204
|
+
]) {
|
|
205
|
+
assert.throws(() => validateHydration(data));
|
|
206
|
+
}
|
|
207
|
+
for (const interval of [0, 4, 241, 5.5, NaN]) assert.throws(() => enable('09:00', interval));
|
|
208
|
+
const state = enable();
|
|
209
|
+
assert.deepEqual(validateHydration(state), state);
|
|
210
|
+
assert.equal(hydrationView(state, null).phase, 'waiting');
|
|
211
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { petLayout, petModel, petArtwork } from '../pet-view.js';
|
|
4
|
+
import { normalizeView } from '../ui-utils.js';
|
|
5
|
+
|
|
6
|
+
test('existing preferences gain the long dress without losing privacy, position or motion choices', () => {
|
|
7
|
+
const old = {
|
|
8
|
+
masked: true,
|
|
9
|
+
companion: false,
|
|
10
|
+
companionMotion: false,
|
|
11
|
+
petSize: 148,
|
|
12
|
+
petPosition: { x: 300, y: 400 },
|
|
13
|
+
opacity: 0.7,
|
|
14
|
+
};
|
|
15
|
+
const next = normalizeView(old);
|
|
16
|
+
assert.equal(next.petModel, 'whale-dress');
|
|
17
|
+
for (const k of Object.keys(old)) assert.deepEqual(next[k], old[k]);
|
|
18
|
+
for (const id of ['whale-dress', 'whale-ornate', 'xiaoxie-original'])
|
|
19
|
+
assert.equal(normalizeView({ ...next, petModel: id }).petModel, id);
|
|
20
|
+
for (const id of ['unknown', '__proto__', 'toString']) {
|
|
21
|
+
assert.equal(normalizeView({ petModel: id }).petModel, 'whale-dress');
|
|
22
|
+
assert.equal(petModel(id).id, 'whale-dress');
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
test('portrait layout fits the complete figure and badge in narrow, short and offset viewports', () => {
|
|
26
|
+
for (const id of ['whale-dress', 'whale-ornate'])
|
|
27
|
+
for (const size of [80, 132, 180])
|
|
28
|
+
for (const viewport of [
|
|
29
|
+
{ width: 1440, height: 1000 },
|
|
30
|
+
{ width: 320, height: 568 },
|
|
31
|
+
{ width: 640, height: 320 },
|
|
32
|
+
{ width: 240, height: 180, left: 50, top: 200 },
|
|
33
|
+
]) {
|
|
34
|
+
const p = petLayout({ x: 9999, y: 9999 }, viewport, size, 420, id),
|
|
35
|
+
m = petModel(id);
|
|
36
|
+
assert.ok(p.x >= (viewport.left ?? 0) && p.y >= (viewport.top ?? 0));
|
|
37
|
+
assert.ok(p.x + p.petWidth <= (viewport.left ?? 0) + viewport.width);
|
|
38
|
+
assert.ok(p.y + p.petHeight <= (viewport.top ?? 0) + viewport.height);
|
|
39
|
+
assert.ok(p.artworkSize * m.widthScale <= p.petWidth);
|
|
40
|
+
assert.ok(p.artworkSize * m.heightScale + 26 <= p.petHeight);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
test('portrait uses an inert image with proportional dimensions; missing resource retains the SVG fallback', () => {
|
|
44
|
+
const h = (type, props, ...children) => ({ type, props, children }),
|
|
45
|
+
presentation = { pose: 'drink', animate: true };
|
|
46
|
+
const im = petArtwork(h, presentation, 132, 'event-1', 'whale-dress', {
|
|
47
|
+
'whale-dress': 'data:image/webp;base64,test',
|
|
48
|
+
});
|
|
49
|
+
assert.equal(im.type, 'img');
|
|
50
|
+
assert.equal(im.props.draggable, false);
|
|
51
|
+
assert.equal(im.props['aria-hidden'], true);
|
|
52
|
+
assert.equal(im.props.width / im.props.height, 640 / 1024);
|
|
53
|
+
assert.equal(im.props['data-animate'], true);
|
|
54
|
+
assert.equal(petArtwork(h, presentation, 132, '', 'whale-dress', {}).type, 'svg');
|
|
55
|
+
assert.equal(petArtwork(h, presentation, 132, '', 'xiaoxie-original', {}).type, 'svg');
|
|
56
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { initialPetUI, petUIReducer, appearanceValues } from '../pet-interactions.js';
|
|
4
|
+
const go = (s, screen, extra = {}) => petUIReducer(s, { type: 'navigate', screen, ...extra });
|
|
5
|
+
|
|
6
|
+
test('one screen is active and editor navigation retains a predictable parent', () => {
|
|
7
|
+
let s = go(initialPetUI, 'home');
|
|
8
|
+
s = go(s, 'income');
|
|
9
|
+
s = go(s, 'water');
|
|
10
|
+
assert.equal(s.screen, 'water');
|
|
11
|
+
assert.equal(s.back, 'income');
|
|
12
|
+
s = go(s, 'appearance');
|
|
13
|
+
assert.equal(s.back, 'income');
|
|
14
|
+
s = go(s, s.back);
|
|
15
|
+
assert.equal(s.screen, 'income');
|
|
16
|
+
s = go(s, null);
|
|
17
|
+
assert.equal(s.screen, null);
|
|
18
|
+
assert.equal(go(initialPetUI, 'appearance').back, null);
|
|
19
|
+
});
|
|
20
|
+
test('dirty navigation preserves the editor until save or explicit discard', () => {
|
|
21
|
+
const editing = go(go(initialPetUI, 'home'), 'water');
|
|
22
|
+
const blocked = go(editing, 'appearance', { dirty: true });
|
|
23
|
+
assert.equal(blocked.screen, 'water');
|
|
24
|
+
assert.deepEqual(blocked.pending, { screen: 'appearance' });
|
|
25
|
+
assert.equal(petUIReducer(blocked, { type: 'stay' }).screen, 'water');
|
|
26
|
+
const discarded = go(blocked, blocked.pending.screen, { discard: true });
|
|
27
|
+
assert.equal(discarded.screen, 'appearance');
|
|
28
|
+
assert.equal(discarded.pending, null);
|
|
29
|
+
const close = go(editing, null, { dirty: true });
|
|
30
|
+
assert.deepEqual(close.pending, { screen: null });
|
|
31
|
+
assert.equal(close.screen, 'water');
|
|
32
|
+
assert.equal(go(close, null, { discard: true }).screen, null);
|
|
33
|
+
});
|
|
34
|
+
test('context menu does not navigate or destroy the pending editor state', () => {
|
|
35
|
+
const original = go(go(initialPetUI, 'home'), 'schedule');
|
|
36
|
+
const menu = petUIReducer(original, { type: 'menu', position: { x: 20, y: 30 } });
|
|
37
|
+
assert.equal(menu.screen, 'schedule');
|
|
38
|
+
assert.equal(menu.back, 'home');
|
|
39
|
+
assert.deepEqual(petUIReducer(menu, { type: 'dismiss-menu' }), original);
|
|
40
|
+
assert.equal(go(menu, 'appearance', { dirty: true }).menu, null);
|
|
41
|
+
assert.deepEqual(original, go(go(initialPetUI, 'home'), 'schedule'));
|
|
42
|
+
});
|
|
43
|
+
test('reopening the current page preserves drafts and unsupported navigation is ignored', () => {
|
|
44
|
+
const s = go(go(initialPetUI, 'home'), 'water');
|
|
45
|
+
assert.deepEqual(go(s, 'water', { dirty: true }), s);
|
|
46
|
+
assert.equal(go(s, 'unknown'), s);
|
|
47
|
+
});
|
|
48
|
+
test('menu cards are independent roots while reminder settings return only to water', () => {
|
|
49
|
+
const water = go(initialPetUI, 'home', { root: true });
|
|
50
|
+
const settings = go(water, 'water');
|
|
51
|
+
assert.equal(settings.back, 'home');
|
|
52
|
+
const clock = go(settings, 'income', { root: true, discard: true });
|
|
53
|
+
assert.equal(clock.back, null);
|
|
54
|
+
const appearance = go(clock, 'appearance', { root: true });
|
|
55
|
+
assert.equal(appearance.back, null);
|
|
56
|
+
assert.equal(go(appearance, appearance.back).screen, null);
|
|
57
|
+
assert.equal(go(initialPetUI, 'earnings', { root: true }).screen, 'earnings');
|
|
58
|
+
});
|
|
59
|
+
test('dirty navigation retains the root destination for explicit save or discard', () => {
|
|
60
|
+
const settings = go(go(initialPetUI, 'home'), 'water');
|
|
61
|
+
const pending = go(settings, 'appearance', { root: true, dirty: true });
|
|
62
|
+
assert.deepEqual(pending.pending, { screen: 'appearance', root: true });
|
|
63
|
+
const next = go(pending, pending.pending.screen, { root: pending.pending.root, discard: true });
|
|
64
|
+
assert.equal(next.back, null);
|
|
65
|
+
assert.equal(next.screen, 'appearance');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('appearance snapshots omit position and salary preferences', () => {
|
|
69
|
+
assert.deepEqual(
|
|
70
|
+
appearanceValues({
|
|
71
|
+
companion: true,
|
|
72
|
+
companionMotion: false,
|
|
73
|
+
petSize: 132,
|
|
74
|
+
petModel: 'whale-ornate',
|
|
75
|
+
opacity: 0.8,
|
|
76
|
+
position: { x: 1, y: 2 },
|
|
77
|
+
petPosition: { x: 3, y: 4 },
|
|
78
|
+
masked: false,
|
|
79
|
+
}),
|
|
80
|
+
{
|
|
81
|
+
companion: true,
|
|
82
|
+
companionMotion: false,
|
|
83
|
+
petSize: 132,
|
|
84
|
+
petModel: 'whale-ornate',
|
|
85
|
+
opacity: 0.8,
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
});
|