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.
Files changed (44) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +48 -26
  3. package/ROADMAP.md +70 -0
  4. package/client.js +5066 -1099
  5. package/client.source.js +1988 -541
  6. package/companion-app.js +1431 -0
  7. package/companion-image.js +59 -0
  8. package/companion-state.js +424 -0
  9. package/companion.js +6 -9
  10. package/countdown.js +83 -0
  11. package/docs/RELEASE-1.2.2.md +20 -0
  12. package/hydration-store.js +55 -16
  13. package/hydration.js +231 -250
  14. package/icon.svg +28 -1
  15. package/index.js +59 -6
  16. package/locale/en.json +1 -1
  17. package/locale/zh.json +1 -1
  18. package/media-manifest.js +29 -0
  19. package/media-server.js +123 -0
  20. package/package.json +13 -3
  21. package/pet-interactions.js +121 -17
  22. package/pet-motion.js +2 -1
  23. package/pet-reminder.js +20 -0
  24. package/pet-surfaces.js +130 -6
  25. package/pet-video.js +221 -72
  26. package/pet-view.js +71 -4
  27. package/settings-api.js +126 -0
  28. package/tests/classic-overview.test.mjs +77 -0
  29. package/tests/companion-state.test.mjs +267 -0
  30. package/tests/companion.test.mjs +12 -12
  31. package/tests/countdown.test.mjs +115 -0
  32. package/tests/host.test.mjs +26 -13
  33. package/tests/hydration-extras.test.mjs +168 -204
  34. package/tests/hydration-store.test.mjs +199 -213
  35. package/tests/hydration.test.mjs +116 -186
  36. package/tests/media-server.test.mjs +179 -0
  37. package/tests/pet-interactions.test.mjs +19 -0
  38. package/tests/pet-reminder.test.mjs +71 -0
  39. package/tests/pet-video.test.mjs +171 -5
  40. package/tests/settings-api.test.mjs +130 -0
  41. package/tests/ui.test.mjs +4 -1
  42. package/tools/build.mjs +45 -16
  43. package/tools/check-package.mjs +12 -0
  44. package/ui-utils.js +44 -0
@@ -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
- module.apply({
155
- effect: (effect) => effect(),
156
- commands: {
157
- register(definition) {
158
- command = definition;
159
- return () => {};
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
- tools: {
163
- register(definition) {
164
- tool = definition;
165
- return () => {};
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
- hydrationContext,
7
+ migrateHydration,
8
8
  hydrationView,
9
9
  transitionHydration,
10
10
  HYDRATION_ACTION_LIMIT,
11
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',
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
- 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);
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
- 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' });
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 = [manual('10:00', 'due', {}, { eventId }), { type: 'drink', eventId }];
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
- 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);
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
- test('manual request IDs are bounded, distinct records count, and old-shift retries cannot count tomorrow', () => {
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, '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');
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
- 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', () => {
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
- { 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));
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
  });