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.
@@ -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 drink feedback is acknowledged once; quiet and break states never celebrate or animate', () => {
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, 'companionDone');
75
- assert.equal(show({ phase: 'paused', workStatus: 'rest' }).message, 'companionDayOff');
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
+ });
@@ -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
- 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
  });