dsh-offwork 1.1.1 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/README.md +48 -26
- package/ROADMAP.md +70 -0
- package/client.js +5066 -1099
- package/client.source.js +1988 -541
- package/companion-app.js +1431 -0
- package/companion-image.js +59 -0
- package/companion-state.js +424 -0
- package/companion.js +6 -9
- package/countdown.js +83 -0
- package/docs/RELEASE-1.2.2.md +20 -0
- package/hydration-store.js +55 -16
- package/hydration.js +231 -250
- package/icon.svg +28 -1
- package/index.js +59 -6
- 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 +13 -3
- package/pet-interactions.js +121 -17
- package/pet-motion.js +2 -1
- package/pet-reminder.js +20 -0
- package/pet-surfaces.js +130 -6
- package/pet-video.js +221 -72
- package/pet-view.js +71 -4
- package/settings-api.js +126 -0
- package/tests/classic-overview.test.mjs +77 -0
- package/tests/companion-state.test.mjs +267 -0
- package/tests/companion.test.mjs +12 -12
- package/tests/countdown.test.mjs +115 -0
- package/tests/host.test.mjs +26 -13
- 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-interactions.test.mjs +19 -0
- package/tests/pet-reminder.test.mjs +71 -0
- package/tests/pet-video.test.mjs +171 -5
- package/tests/settings-api.test.mjs +130 -0
- package/tests/ui.test.mjs +4 -1
- package/tools/build.mjs +45 -16
- package/tools/check-package.mjs +12 -0
- package/ui-utils.js +44 -0
package/tests/host.test.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { mkdtemp, readFile, writeFile, stat, readdir, rm } from 'node:fs/promise
|
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join, resolve, dirname, basename } from 'node:path';
|
|
6
6
|
import { pathToFileURL } from 'node:url';
|
|
7
|
+
import { createSettingsAccess } from '../settings-api.js';
|
|
7
8
|
import { createOperations, DEFAULT_SETTINGS, validateSettings } from '../index.js';
|
|
8
9
|
|
|
9
10
|
const source = new URL('../index.js', import.meta.url);
|
|
@@ -148,31 +149,43 @@ async function adapters(t) {
|
|
|
148
149
|
// and neither adapter can accidentally reach the user's actual data.json.
|
|
149
150
|
const moduleFile = join(fixtureData.dir, 'host.mjs');
|
|
150
151
|
await writeFile(moduleFile, await readFile(source, 'utf8'));
|
|
152
|
+
await writeFile(join(fixtureData.dir, 'package.json'), '{"type":"module"}');
|
|
153
|
+
for (const name of ['media-server.js', 'media-manifest.js', 'settings-api.js'])
|
|
154
|
+
await writeFile(
|
|
155
|
+
join(fixtureData.dir, name),
|
|
156
|
+
await readFile(new URL('../' + name, import.meta.url)),
|
|
157
|
+
);
|
|
151
158
|
const module = await import(pathToFileURL(moduleFile).href);
|
|
152
159
|
let command;
|
|
153
160
|
let tool;
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
161
|
+
const access = createSettingsAccess(fixtureData.dir);
|
|
162
|
+
await access.set(true);
|
|
163
|
+
module.installOperations(
|
|
164
|
+
{
|
|
165
|
+
effect: (effect) => effect(),
|
|
166
|
+
commands: {
|
|
167
|
+
register(definition) {
|
|
168
|
+
command = definition;
|
|
169
|
+
return () => {};
|
|
170
|
+
},
|
|
160
171
|
},
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
172
|
+
tools: {
|
|
173
|
+
register(definition) {
|
|
174
|
+
tool = definition;
|
|
175
|
+
return () => {};
|
|
176
|
+
},
|
|
166
177
|
},
|
|
167
178
|
},
|
|
168
|
-
|
|
179
|
+
fixtureData.operate,
|
|
180
|
+
access,
|
|
181
|
+
);
|
|
169
182
|
const runCommand = async (rawInput) => {
|
|
170
183
|
const response = await command.handler({ rawInput });
|
|
171
184
|
const result = JSON.parse(response.text);
|
|
172
185
|
assert.equal(response.kind, result.ok ? 'success' : 'error');
|
|
173
186
|
return result;
|
|
174
187
|
};
|
|
175
|
-
return { ...fixtureData, tool, command, runCommand };
|
|
188
|
+
return { ...fixtureData, tool, command, runCommand, access };
|
|
176
189
|
}
|
|
177
190
|
|
|
178
191
|
test('command and tool share results, validation, storage and a write queue', async (t) => {
|
|
@@ -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
|
});
|