dsh-offwork 1.1.1 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +36 -21
- package/client.js +2558 -822
- package/client.source.js +1592 -432
- package/companion.js +6 -9
- package/countdown.js +83 -0
- package/hydration-store.js +55 -16
- package/hydration.js +231 -250
- package/index.js +2 -0
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/media-manifest.js +29 -0
- package/media-server.js +123 -0
- package/package.json +6 -2
- package/pet-interactions.js +80 -9
- package/pet-motion.js +2 -1
- package/pet-reminder.js +20 -0
- package/pet-surfaces.js +129 -5
- package/pet-video.js +221 -72
- package/pet-view.js +69 -4
- package/tests/classic-overview.test.mjs +77 -0
- package/tests/companion.test.mjs +12 -12
- package/tests/countdown.test.mjs +115 -0
- package/tests/host.test.mjs +6 -0
- package/tests/hydration-extras.test.mjs +168 -204
- package/tests/hydration-store.test.mjs +199 -213
- package/tests/hydration.test.mjs +116 -186
- package/tests/media-server.test.mjs +179 -0
- package/tests/pet-reminder.test.mjs +71 -0
- package/tests/pet-video.test.mjs +171 -5
- package/tools/build.mjs +39 -16
- package/tools/check-package.mjs +8 -0
- package/ui-utils.js +44 -0
package/tests/companion.test.mjs
CHANGED
|
@@ -16,25 +16,25 @@ test('only a leased due reminder changes the character to a drinking pose; it ad
|
|
|
16
16
|
assert.equal(here.announce, false);
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
-
test('explicit
|
|
19
|
+
test('explicit drinking works outside work hours while manual quiet/pause still suppress animation', () => {
|
|
20
20
|
const happy = show({ feedback: drink });
|
|
21
21
|
assert.equal(happy.pose, 'happy');
|
|
22
22
|
assert.equal(happy.animate, true);
|
|
23
23
|
assert.equal(happy.announce, true);
|
|
24
|
-
for (const patch of [
|
|
25
|
-
{ phase: 'quiet' },
|
|
26
|
-
{ phase: 'paused' },
|
|
27
|
-
{ workStatus: 'lunch' },
|
|
28
|
-
{ workStatus: 'rest' },
|
|
29
|
-
{ workStatus: 'done' },
|
|
30
|
-
{ workStatus: 'before' },
|
|
31
|
-
]) {
|
|
24
|
+
for (const patch of [{ phase: 'quiet' }, { phase: 'paused' }]) {
|
|
32
25
|
const result = show({ ...patch, feedback: drink });
|
|
33
26
|
assert.equal(result.animate, false);
|
|
34
27
|
assert.equal(result.message, 'waterRecorded');
|
|
35
28
|
}
|
|
29
|
+
for (const workStatus of ['lunch', 'rest', 'done', 'before', undefined]) {
|
|
30
|
+
const result = show({ workStatus, feedback: drink });
|
|
31
|
+
assert.equal(result.pose, 'happy');
|
|
32
|
+
assert.equal(result.animate, true);
|
|
33
|
+
const due = show({ workStatus, phase: 'due', ownsPrompt: true });
|
|
34
|
+
assert.equal(due.pose, 'drink');
|
|
35
|
+
assert.equal(due.message, null);
|
|
36
|
+
}
|
|
36
37
|
assert.equal(show({ phase: 'quiet', feedback: drink }).pose, 'quiet');
|
|
37
|
-
assert.equal(show({ workStatus: 'lunch', feedback: drink }).pose, 'rest');
|
|
38
38
|
assert.equal(show({ feedback: { ...drink, kind: 'notice' } }).animate, false);
|
|
39
39
|
});
|
|
40
40
|
|
|
@@ -71,8 +71,8 @@ test('quiet, paused and work states have stable localized messages with no autom
|
|
|
71
71
|
assert.deepEqual(show(input), result);
|
|
72
72
|
}
|
|
73
73
|
assert.notEqual(show({ phase: 'quiet' }).message, show({}).message);
|
|
74
|
-
assert.equal(show({ phase: 'paused', workStatus: 'done' }).message, '
|
|
75
|
-
assert.equal(show({ phase: 'paused', workStatus: 'rest' }).message, '
|
|
74
|
+
assert.equal(show({ phase: 'paused', workStatus: 'done' }).message, 'companionPaused');
|
|
75
|
+
assert.equal(show({ phase: 'paused', workStatus: 'rest' }).message, 'companionPaused');
|
|
76
76
|
});
|
|
77
77
|
|
|
78
78
|
test('existing view data keeps its position, privacy and opacity while explicit companion opt-out survives reload', () => {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { IDBFactory } from 'fake-indexeddb';
|
|
4
|
+
import {
|
|
5
|
+
defaultCountdown,
|
|
6
|
+
validateCountdown,
|
|
7
|
+
transitionCountdown,
|
|
8
|
+
countdownView,
|
|
9
|
+
SMOKING_TIMER_DB,
|
|
10
|
+
} from '../countdown.js';
|
|
11
|
+
import { createStateStore, createHydrationStore } from '../hydration-store.js';
|
|
12
|
+
import { petLayout } from '../pet-view.js';
|
|
13
|
+
import { petUIReducer, initialPetUI } from '../pet-interactions.js';
|
|
14
|
+
|
|
15
|
+
test('wall-clock countdown stays idle until started, expires without restarting and survives serialization', () => {
|
|
16
|
+
const initial = defaultCountdown();
|
|
17
|
+
assert.equal(countdownView(initial, 8000000).phase, 'idle');
|
|
18
|
+
const running = transitionCountdown(initial, { type: 'start' }, 1000);
|
|
19
|
+
const restored = validateCountdown(JSON.parse(JSON.stringify(running)));
|
|
20
|
+
assert.equal(countdownView(restored, 31000).remaining, 3570000);
|
|
21
|
+
assert.deepEqual(countdownView(restored, 4000000), { phase: 'due', remaining: 0 });
|
|
22
|
+
assert.deepEqual(countdownView(restored, 5000000), { phase: 'due', remaining: 0 });
|
|
23
|
+
assert.equal(transitionCountdown(restored, { type: 'pause' }, 5000000).status, 'running');
|
|
24
|
+
assert.equal(transitionCountdown(restored, { type: 'start' }, 5000000).deadline, 8600000);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('pause freezes the remainder; resume and explicit restart have different semantics', () => {
|
|
28
|
+
const running = transitionCountdown(defaultCountdown(), { type: 'start' }, 1000);
|
|
29
|
+
const paused = transitionCountdown(running, { type: 'pause' }, 31000);
|
|
30
|
+
assert.equal(countdownView(paused, 9000000).remaining, 3570000);
|
|
31
|
+
const changed = transitionCountdown(paused, { type: 'interval', minutes: 5 }, 9000000);
|
|
32
|
+
assert.equal(changed.remaining, paused.remaining);
|
|
33
|
+
const resumed = transitionCountdown(changed, { type: 'resume' }, 9000000);
|
|
34
|
+
assert.equal(resumed.deadline, 12570000);
|
|
35
|
+
assert.equal(transitionCountdown(resumed, { type: 'restart' }, 9000000).deadline, 9300000);
|
|
36
|
+
assert.equal(transitionCountdown(resumed, { type: 'stop' }, 9000000).status, 'idle');
|
|
37
|
+
assert.equal(transitionCountdown(resumed, { type: 'start' }, 9000000).deadline, resumed.deadline);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('invalid or future timer data is rejected rather than resetting the countdown', () => {
|
|
41
|
+
for (const change of [
|
|
42
|
+
{ schema: 2 },
|
|
43
|
+
{ revision: -1 },
|
|
44
|
+
{ minutes: 0 },
|
|
45
|
+
{ minutes: 1441 },
|
|
46
|
+
{ remaining: NaN },
|
|
47
|
+
{ status: 'unknown' },
|
|
48
|
+
{ status: 'paused', remaining: 0 },
|
|
49
|
+
{ status: 'idle', deadline: 1 },
|
|
50
|
+
{ status: 'running', deadline: 1, remaining: 2 },
|
|
51
|
+
])
|
|
52
|
+
assert.throws(() => validateCountdown({ ...defaultCountdown(), ...change }));
|
|
53
|
+
assert.throws(() =>
|
|
54
|
+
transitionCountdown(defaultCountdown(), { type: 'interval', minutes: 2.5 }, 0),
|
|
55
|
+
);
|
|
56
|
+
assert.throws(() =>
|
|
57
|
+
transitionCountdown(defaultCountdown(), { type: 'start' }, Number.MAX_SAFE_INTEGER),
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('timer storage is atomic across tabs and separate from hydration records', async () => {
|
|
62
|
+
const idb = new IDBFactory();
|
|
63
|
+
const first = createStateStore(idb, SMOKING_TIMER_DB, defaultCountdown, validateCountdown);
|
|
64
|
+
const second = createStateStore(idb, SMOKING_TIMER_DB, defaultCountdown, validateCountdown);
|
|
65
|
+
const water = createHydrationStore(idb);
|
|
66
|
+
const original = await water.update((state) => state);
|
|
67
|
+
const results = await Promise.all(
|
|
68
|
+
[first, second].map((store) =>
|
|
69
|
+
store.update((state) =>
|
|
70
|
+
state.revision === 0 ? transitionCountdown(state, { type: 'start' }, 1000) : state,
|
|
71
|
+
),
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
assert.equal(results[0].revision, 1);
|
|
75
|
+
assert.equal(results[1].revision, 1);
|
|
76
|
+
const stopped = await second.update((state) =>
|
|
77
|
+
transitionCountdown(state, { type: 'stop' }, 2000),
|
|
78
|
+
);
|
|
79
|
+
assert.equal(stopped.revision, 2);
|
|
80
|
+
assert.deepEqual(await first.update((state) => state), stopped);
|
|
81
|
+
assert.deepEqual(await water.update((state) => state), original);
|
|
82
|
+
first.close();
|
|
83
|
+
second.close();
|
|
84
|
+
water.close();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('independent timer cards keep navigation draft protection and reserve space below the character', () => {
|
|
88
|
+
for (const screen of ['water-countdown', 'smoking']) {
|
|
89
|
+
assert.equal(
|
|
90
|
+
petUIReducer(initialPetUI, { type: 'navigate', screen, root: true }).screen,
|
|
91
|
+
screen,
|
|
92
|
+
);
|
|
93
|
+
const dirty = { screen: 'appearance', back: null, menu: null, pending: null };
|
|
94
|
+
const guarded = petUIReducer(dirty, { type: 'navigate', screen, root: true, dirty: true });
|
|
95
|
+
assert.equal(guarded.screen, 'appearance');
|
|
96
|
+
assert.equal(guarded.pending.screen, screen);
|
|
97
|
+
}
|
|
98
|
+
for (const [width, height] of [
|
|
99
|
+
[320, 568],
|
|
100
|
+
[640, 360],
|
|
101
|
+
[390, 844],
|
|
102
|
+
]) {
|
|
103
|
+
const layout = petLayout(
|
|
104
|
+
{ x: 9999, y: 9999 },
|
|
105
|
+
{ width, height },
|
|
106
|
+
220,
|
|
107
|
+
420,
|
|
108
|
+
'whale-ornate-actions',
|
|
109
|
+
90,
|
|
110
|
+
);
|
|
111
|
+
assert.ok(layout.y + layout.petHeight <= height - 12);
|
|
112
|
+
assert.ok(layout.x + layout.petWidth <= width - 12);
|
|
113
|
+
assert.ok(layout.petHeight >= layout.artworkSize * 2.1 + 90);
|
|
114
|
+
}
|
|
115
|
+
});
|
package/tests/host.test.mjs
CHANGED
|
@@ -148,6 +148,12 @@ async function adapters(t) {
|
|
|
148
148
|
// and neither adapter can accidentally reach the user's actual data.json.
|
|
149
149
|
const moduleFile = join(fixtureData.dir, 'host.mjs');
|
|
150
150
|
await writeFile(moduleFile, await readFile(source, 'utf8'));
|
|
151
|
+
await writeFile(join(fixtureData.dir, 'package.json'), '{"type":"module"}');
|
|
152
|
+
for (const name of ['media-server.js', 'media-manifest.js'])
|
|
153
|
+
await writeFile(
|
|
154
|
+
join(fixtureData.dir, name),
|
|
155
|
+
await readFile(new URL('../' + name, import.meta.url)),
|
|
156
|
+
);
|
|
151
157
|
const module = await import(pathToFileURL(moduleFile).href);
|
|
152
158
|
let command;
|
|
153
159
|
let tool;
|
|
@@ -4,223 +4,187 @@ import { calculate } from '../clock.js';
|
|
|
4
4
|
import {
|
|
5
5
|
defaultHydration,
|
|
6
6
|
validateHydration,
|
|
7
|
-
|
|
7
|
+
migrateHydration,
|
|
8
8
|
hydrationView,
|
|
9
9
|
transitionHydration,
|
|
10
10
|
HYDRATION_ACTION_LIMIT,
|
|
11
11
|
} from '../hydration.js';
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
12
|
+
const time = (value) => Date.parse(value.includes('T') ? value : `2026-11-06T${value}:00+08:00`);
|
|
13
|
+
const act = (state, at, action) => transitionHydration(state, action, time(at), 'a');
|
|
14
|
+
const view = (state, at) => hydrationView(state, time(at));
|
|
15
|
+
const enable = (at = '09:00', interval = 60) =>
|
|
16
|
+
act(defaultHydration(), at, { type: 'preferences', enabled: true, interval });
|
|
17
|
+
const legacy = (at = '09:00', schema = 2) => {
|
|
18
|
+
const settings = {
|
|
19
|
+
timezone: 'Asia/Shanghai',
|
|
20
|
+
start: '09:00',
|
|
21
|
+
end: '18:00',
|
|
22
|
+
breakStart: '12:00',
|
|
23
|
+
breakEnd: '13:00',
|
|
24
|
+
weekdays: [1, 2, 3, 4, 5],
|
|
25
|
+
payMode: 'daily',
|
|
26
|
+
dailySalary: 1,
|
|
27
|
+
};
|
|
28
|
+
const clock = calculate(settings, time(at));
|
|
29
|
+
return {
|
|
30
|
+
schema,
|
|
31
|
+
revision: 12,
|
|
32
|
+
enabled: true,
|
|
33
|
+
interval: 60,
|
|
34
|
+
shift: {
|
|
35
|
+
id: JSON.stringify([settings.timezone, clock.shiftDate]),
|
|
36
|
+
config: JSON.stringify([
|
|
37
|
+
settings.timezone,
|
|
38
|
+
settings.start,
|
|
39
|
+
settings.end,
|
|
40
|
+
settings.breakStart,
|
|
41
|
+
settings.breakEnd,
|
|
42
|
+
settings.weekdays,
|
|
43
|
+
]),
|
|
44
|
+
schedule: JSON.stringify([
|
|
45
|
+
settings.start,
|
|
46
|
+
settings.end,
|
|
47
|
+
settings.breakStart,
|
|
48
|
+
settings.breakEnd,
|
|
49
|
+
settings.weekdays,
|
|
50
|
+
clock.shiftStart,
|
|
51
|
+
clock.shiftEnd,
|
|
52
|
+
]),
|
|
53
|
+
count: 7,
|
|
54
|
+
paused: false,
|
|
55
|
+
due: clock.elapsed * 1000 + 3600000,
|
|
56
|
+
sequence: 3,
|
|
57
|
+
claim: { owner: 'old', until: time(at) + 8000, sequence: 3 },
|
|
58
|
+
...(schema === 2 ? { quietUntil: 0, recentActions: ['old-drink'] } : {}),
|
|
59
|
+
},
|
|
60
|
+
};
|
|
23
61
|
};
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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);
|
|
62
|
+
test('manual drinking is available before work, at lunch, after work and on days off without a schedule', () => {
|
|
63
|
+
for (const at of ['08:00', '12:30', '20:00', '2026-11-07T10:00:00+08:00']) {
|
|
64
|
+
const result = act(defaultHydration(), at, { type: 'manual-drink', actionId: at });
|
|
65
|
+
assert.equal(result.count, 1);
|
|
66
|
+
assert.equal(result.enabled, false);
|
|
67
|
+
assert.equal(result.dueAt, 0);
|
|
65
68
|
}
|
|
66
69
|
});
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
assert.equal(
|
|
71
|
-
assert.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
70
|
+
test('manual retries deduplicate and successful records reset only an enabled, unpaused timer', () => {
|
|
71
|
+
const action = { type: 'manual-drink', actionId: 'cup' };
|
|
72
|
+
const first = act(enable(), '09:15', action);
|
|
73
|
+
assert.equal(first.dueAt, time('10:15'));
|
|
74
|
+
assert.deepEqual(act(first, '09:20', action), first);
|
|
75
|
+
const paused = act(first, '09:21', { type: 'pause' });
|
|
76
|
+
const second = act(paused, '20:00', { type: 'manual-drink', actionId: 'next' });
|
|
77
|
+
assert.equal(second.count, 2);
|
|
78
|
+
assert.equal(second.paused, true);
|
|
79
|
+
assert.equal(second.dueAt, paused.dueAt);
|
|
80
|
+
});
|
|
81
|
+
test('manual and prompted acknowledgments of the same overdue event count once in either order', () => {
|
|
82
|
+
const initial = act(enable(), '10:00', { type: 'claim' });
|
|
78
83
|
const eventId = view(initial, '10:00').eventId;
|
|
79
|
-
const actions = [
|
|
84
|
+
const actions = [
|
|
85
|
+
{ type: 'manual-drink', actionId: 'same', eventId },
|
|
86
|
+
{ type: 'drink', eventId },
|
|
87
|
+
];
|
|
80
88
|
for (const order of [actions, [...actions].reverse()]) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
assert.equal(
|
|
84
|
-
assert.equal(state.shift.claim, null);
|
|
89
|
+
const result = order.reduce((s, a) => act(s, '10:00', a), initial);
|
|
90
|
+
assert.equal(result.count, 1);
|
|
91
|
+
assert.equal(result.claim, null);
|
|
85
92
|
}
|
|
86
93
|
});
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
let state = enabled();
|
|
94
|
+
test('recent action IDs are bounded and persist across calendar dates', () => {
|
|
95
|
+
let state = defaultHydration();
|
|
90
96
|
for (let i = 0; i < HYDRATION_ACTION_LIMIT + 3; i++)
|
|
91
|
-
state = act(state, '
|
|
92
|
-
assert.equal(state.
|
|
93
|
-
assert.equal(state.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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');
|
|
97
|
+
state = act(state, '21:00', { type: 'manual-drink', actionId: 'cup-' + i });
|
|
98
|
+
assert.equal(state.count, HYDRATION_ACTION_LIMIT + 3);
|
|
99
|
+
assert.equal(state.recentActions.length, HYDRATION_ACTION_LIMIT);
|
|
100
|
+
const tomorrow = act(state, '2026-11-07T10:00:00+08:00', { type: 'tick' });
|
|
101
|
+
assert.deepEqual(tomorrow, state);
|
|
102
|
+
});
|
|
103
|
+
test('quiet time does not end at the end of a work shift or at midnight', () => {
|
|
104
|
+
for (const [at, end] of [
|
|
105
|
+
['17:50', '18:50'],
|
|
106
|
+
['23:50', '2026-11-07T00:50:00+08:00'],
|
|
107
|
+
]) {
|
|
108
|
+
const state = act(enable(at, 5), at, { type: 'quiet', minutes: 60 });
|
|
109
|
+
assert.equal(state.quietUntil, time(end));
|
|
110
|
+
assert.equal(view(state, new Date(time(end) - 60000).toISOString()).phase, 'quiet');
|
|
111
|
+
assert.equal(view(state, end).phase, 'due');
|
|
135
112
|
}
|
|
136
113
|
});
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
state = act(state, '
|
|
141
|
-
assert.equal(state.
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
assert.equal(state.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
assert.equal(
|
|
155
|
-
|
|
156
|
-
assert.equal(
|
|
157
|
-
|
|
158
|
-
assert.
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
test(
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
legacy.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
assert.equal(
|
|
192
|
-
assert.equal(
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
assert.equal(
|
|
196
|
-
assert.equal(
|
|
197
|
-
|
|
198
|
-
|
|
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', () => {
|
|
114
|
+
test('quiet operations invalidate old reminders; a successful drink keeps the chosen quiet deadline', () => {
|
|
115
|
+
let state = act(enable(), '10:00', { type: 'claim' });
|
|
116
|
+
const eventId = view(state, '10:00').eventId;
|
|
117
|
+
state = act(state, '10:00', { type: 'quiet', minutes: 30 });
|
|
118
|
+
assert.equal(state.claim, null);
|
|
119
|
+
assert.equal(act(state, '10:01', { type: 'drink', eventId }).count, 0);
|
|
120
|
+
state = act(state, '10:10', { type: 'manual-drink', actionId: 'quiet-cup' });
|
|
121
|
+
assert.equal(state.count, 1);
|
|
122
|
+
assert.equal(state.quietUntil, time('10:30'));
|
|
123
|
+
assert.equal(state.dueAt, time('11:10'));
|
|
124
|
+
const ended = act(state, '10:11', { type: 'end-quiet' });
|
|
125
|
+
assert.equal(ended.quietUntil, 0);
|
|
126
|
+
assert.equal(ended.dueAt, state.dueAt);
|
|
127
|
+
});
|
|
128
|
+
test('pause and disable end quiet time, while resume starts a full interval', () => {
|
|
129
|
+
const state = act(enable(), '09:10', { type: 'quiet', minutes: 60 });
|
|
130
|
+
const paused = act(state, '09:20', { type: 'pause' });
|
|
131
|
+
assert.equal(paused.quietUntil, 0);
|
|
132
|
+
assert.equal(act(paused, '09:21', { type: 'quiet', minutes: 30 }).quietUntil, 0);
|
|
133
|
+
assert.equal(act(state, '09:20', { type: 'preferences', enabled: false }).quietUntil, 0);
|
|
134
|
+
assert.equal(act(paused, '22:00', { type: 'resume' }).dueAt, time('23:00'));
|
|
135
|
+
assert.throws(() => act(enable(), '09:00', { type: 'quiet', minutes: 20 }));
|
|
136
|
+
});
|
|
137
|
+
for (const schema of [1, 2])
|
|
138
|
+
test(`schema ${schema} migration retains preferences/count and converts a matching shift remainder once`, () => {
|
|
139
|
+
const old = legacy('09:00', schema),
|
|
140
|
+
snapshot = structuredClone(old);
|
|
141
|
+
const now = time('09:20');
|
|
142
|
+
const next = migrateHydration(old, now);
|
|
143
|
+
assert.equal(next.schema, 3);
|
|
144
|
+
assert.equal(next.count, 7);
|
|
145
|
+
assert.equal(next.enabled, true);
|
|
146
|
+
assert.equal(next.interval, 60);
|
|
147
|
+
assert.equal(next.dueAt, time('10:00'));
|
|
148
|
+
assert.equal(next.claim, null);
|
|
149
|
+
assert.ok(next.sequence > old.shift.sequence);
|
|
150
|
+
assert.deepEqual(next.recentActions, schema === 2 ? ['old-drink'] : []);
|
|
151
|
+
assert.deepEqual(old, snapshot);
|
|
152
|
+
assert.deepEqual(validateHydration(JSON.parse(JSON.stringify(next))), next);
|
|
153
|
+
});
|
|
154
|
+
test('migration turns remaining paid minutes during lunch into natural minutes without losing quiet settings', () => {
|
|
155
|
+
const old = legacy('11:50');
|
|
156
|
+
old.shift.quietUntil = time('13:30');
|
|
157
|
+
const next = migrateHydration(old, time('12:20'));
|
|
158
|
+
assert.equal(next.dueAt, time('13:10'));
|
|
159
|
+
assert.equal(next.quietUntil, time('13:30'));
|
|
160
|
+
assert.equal(next.count, 7);
|
|
161
|
+
});
|
|
162
|
+
test('migration preserves an already-overdue event; old-day records and pause retain counts without guessing past drinks', () => {
|
|
163
|
+
assert.equal(migrateHydration(legacy(), time('10:30')).dueAt, time('10:30'));
|
|
164
|
+
const old = legacy();
|
|
165
|
+
old.shift.paused = true;
|
|
166
|
+
const paused = migrateHydration(old, time('2026-11-07T10:00:00+08:00'));
|
|
167
|
+
assert.equal(paused.paused, true);
|
|
168
|
+
assert.equal(paused.count, 7);
|
|
169
|
+
assert.equal(view(paused, '2026-11-07T10:00:00+08:00').phase, 'paused');
|
|
170
|
+
old.shift.paused = false;
|
|
171
|
+
const next = migrateHydration(old, time('2026-11-07T10:00:00+08:00'));
|
|
172
|
+
assert.equal(next.count, 7);
|
|
173
|
+
assert.equal(next.dueAt, time('2026-11-07T11:00:00+08:00'));
|
|
174
|
+
});
|
|
175
|
+
test('legacy malformed/future data is rejected; incomplete historical schedule text retains records with a full new interval', () => {
|
|
212
176
|
for (const patch of [
|
|
213
|
-
{
|
|
214
|
-
{
|
|
215
|
-
{
|
|
216
|
-
{
|
|
217
|
-
{
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
assert.
|
|
177
|
+
{ schema: 99 },
|
|
178
|
+
{ revision: -1 },
|
|
179
|
+
{ interval: 1 },
|
|
180
|
+
{ enabled: 0 },
|
|
181
|
+
{ shift: {} },
|
|
182
|
+
{ other: true },
|
|
183
|
+
])
|
|
184
|
+
assert.throws(() => migrateHydration({ ...legacy(), ...patch }, time('09:00')));
|
|
185
|
+
const old = legacy();
|
|
186
|
+
old.shift.config = 'unavailable old schedule';
|
|
187
|
+
const next = migrateHydration(old, time('09:20'));
|
|
188
|
+
assert.equal(next.count, 7);
|
|
189
|
+
assert.equal(next.dueAt, time('10:20'));
|
|
226
190
|
});
|