dsh-offwork 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/README.md +77 -43
- package/assets/characters/NOTICE.md +7 -0
- package/assets/characters/whale-dress.webp +0 -0
- package/assets/characters/whale-ornate.webp +0 -0
- package/client.js +4410 -327
- package/client.source.js +3093 -278
- package/clock.js +81 -34
- package/companion.js +185 -0
- package/hydration-store.js +101 -0
- package/hydration.js +303 -0
- package/index.js +142 -66
- package/locale/en.json +6 -1
- package/locale/zh.json +6 -1
- package/package.json +27 -6
- package/pet-interactions.js +138 -0
- package/pet-surfaces.js +194 -0
- package/pet-view.js +272 -0
- package/tests/clock.test.mjs +76 -33
- package/tests/companion.test.mjs +86 -0
- package/tests/host.test.mjs +76 -27
- package/tests/hydration-extras.test.mjs +226 -0
- package/tests/hydration-store.test.mjs +287 -0
- package/tests/hydration.test.mjs +211 -0
- package/tests/pet-character.test.mjs +56 -0
- package/tests/pet-interactions.test.mjs +88 -0
- package/tests/pet-surfaces.test.mjs +128 -0
- package/tests/pet-view.test.mjs +48 -0
- package/tests/ui.test.mjs +98 -21
- package/tools/build.mjs +52 -7
- package/tools/check-package.mjs +105 -0
- package/ui-utils.js +37 -14
package/tests/clock.test.mjs
CHANGED
|
@@ -3,23 +3,35 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import { calculate } from '../clock.js';
|
|
4
4
|
|
|
5
5
|
const base = {
|
|
6
|
-
monthlySalary: 10000,
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
monthlySalary: 10000,
|
|
7
|
+
workdaysPerMonth: 20,
|
|
8
|
+
start: '09:00',
|
|
9
|
+
end: '18:00',
|
|
10
|
+
breakStart: '12:00',
|
|
11
|
+
breakEnd: '13:00',
|
|
12
|
+
weekdays: [1, 2, 3, 4, 5],
|
|
13
|
+
timezone: 'Asia/Shanghai',
|
|
9
14
|
};
|
|
10
15
|
const at = (time, patch = {}) => calculate({ ...base, ...patch }, Date.parse(time));
|
|
11
16
|
const night = { start: '22:00', end: '06:00', breakStart: '02:00', breakEnd: '03:00' };
|
|
12
17
|
const utc = { timezone: 'UTC' };
|
|
13
18
|
|
|
14
19
|
for (const [time, status, progress] of [
|
|
15
|
-
['08:00', 'before', 0],
|
|
16
|
-
['
|
|
17
|
-
['
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
['08:00', 'before', 0],
|
|
21
|
+
['09:00', 'working', 0],
|
|
22
|
+
['11:00', 'working', 0.25],
|
|
23
|
+
['12:00', 'lunch', 0.375],
|
|
24
|
+
['12:59', 'lunch', 0.375],
|
|
25
|
+
['13:00', 'working', 0.375],
|
|
26
|
+
['17:00', 'working', 0.875],
|
|
27
|
+
['18:00', 'done', 1],
|
|
28
|
+
['23:59', 'done', 1],
|
|
29
|
+
])
|
|
30
|
+
test(`same-day boundary ${time}: ${status}`, () => {
|
|
31
|
+
const result = at(`2026-11-02T${time}:00+08:00`);
|
|
32
|
+
assert.equal(result.status, status);
|
|
33
|
+
assert.equal(result.progress, progress);
|
|
34
|
+
});
|
|
23
35
|
|
|
24
36
|
test('before has both actual end countdown and start countdown', () => {
|
|
25
37
|
const result = at('2026-11-02T08:00:00+08:00');
|
|
@@ -65,7 +77,11 @@ test('night break after midnight is normalized into the shift', () => {
|
|
|
65
77
|
assert.equal(result.total, 7 * 3600);
|
|
66
78
|
});
|
|
67
79
|
test('break spanning midnight is normalized', () => {
|
|
68
|
-
const result = at('2026-11-03T00:15:00+08:00', {
|
|
80
|
+
const result = at('2026-11-03T00:15:00+08:00', {
|
|
81
|
+
...night,
|
|
82
|
+
breakStart: '23:30',
|
|
83
|
+
breakEnd: '00:30',
|
|
84
|
+
});
|
|
69
85
|
assert.equal(result.status, 'lunch');
|
|
70
86
|
assert.equal(result.elapsed, 1.5 * 3600);
|
|
71
87
|
});
|
|
@@ -109,7 +125,10 @@ test('configured Sunday workday overrides weekday assumptions', () => {
|
|
|
109
125
|
});
|
|
110
126
|
test('daily salary ignores monthly fields and truncates cents', () => {
|
|
111
127
|
const result = at('2026-11-02T18:00:00+08:00', {
|
|
112
|
-
payMode: 'daily',
|
|
128
|
+
payMode: 'daily',
|
|
129
|
+
dailySalary: 123.459,
|
|
130
|
+
monthlySalary: undefined,
|
|
131
|
+
workdaysPerMonth: undefined,
|
|
113
132
|
});
|
|
114
133
|
assert.equal(result.payMode, 'daily');
|
|
115
134
|
assert.equal(result.daily, 123.45);
|
|
@@ -148,7 +167,13 @@ test('fractional-zone offset is handled independently of system timezone', () =>
|
|
|
148
167
|
assert.equal(result.progress, 0);
|
|
149
168
|
});
|
|
150
169
|
test('DST spring night countdown measures real elapsed seconds', () => {
|
|
151
|
-
const patch = {
|
|
170
|
+
const patch = {
|
|
171
|
+
...night,
|
|
172
|
+
timezone: 'America/New_York',
|
|
173
|
+
weekdays: [6],
|
|
174
|
+
breakStart: '00:00',
|
|
175
|
+
breakEnd: '00:00',
|
|
176
|
+
};
|
|
152
177
|
const result = at('2026-03-08T06:00:00Z', patch);
|
|
153
178
|
assert.equal(result.shiftDate, '2026-03-07');
|
|
154
179
|
assert.equal(result.total, 7 * 3600);
|
|
@@ -156,7 +181,13 @@ test('DST spring night countdown measures real elapsed seconds', () => {
|
|
|
156
181
|
assert.equal(result.remaining, 4 * 3600);
|
|
157
182
|
});
|
|
158
183
|
test('DST fall night countdown includes the repeated hour', () => {
|
|
159
|
-
const patch = {
|
|
184
|
+
const patch = {
|
|
185
|
+
...night,
|
|
186
|
+
timezone: 'America/New_York',
|
|
187
|
+
weekdays: [6],
|
|
188
|
+
breakStart: '00:00',
|
|
189
|
+
breakEnd: '00:00',
|
|
190
|
+
};
|
|
160
191
|
const result = at('2026-11-01T06:30:00Z', patch);
|
|
161
192
|
assert.equal(result.total, 9 * 3600);
|
|
162
193
|
assert.equal(result.elapsed, 4.5 * 3600);
|
|
@@ -169,25 +200,37 @@ test('subsecond countdown rounds upwards and salary remains truncated', () => {
|
|
|
169
200
|
});
|
|
170
201
|
for (const [label, patch, begin] of [
|
|
171
202
|
['monthly lunch', {}, '2026-11-02T00:00:00+08:00'],
|
|
172
|
-
[
|
|
173
|
-
|
|
203
|
+
[
|
|
204
|
+
'daily no lunch',
|
|
205
|
+
{ payMode: 'daily', dailySalary: 109.99, breakStart: '00:00', breakEnd: '00:00' },
|
|
206
|
+
'2026-11-02T00:00:00+08:00',
|
|
207
|
+
],
|
|
208
|
+
[
|
|
209
|
+
'night cross-midnight lunch',
|
|
210
|
+
{ ...night, breakStart: '23:30', breakEnd: '00:30' },
|
|
211
|
+
'2026-11-02T22:00:00+08:00',
|
|
212
|
+
],
|
|
174
213
|
['decimal cents', { monthlySalary: 0.29, workdaysPerMonth: 1 }, '2026-11-02T00:00:00+08:00'],
|
|
175
|
-
])
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
});
|
|
214
|
+
])
|
|
215
|
+
test(`whole-day minute grid: monotone and bounded (${label})`, () => {
|
|
216
|
+
let previous = 0;
|
|
217
|
+
const start = Date.parse(begin);
|
|
218
|
+
// Stop before the next civil day / shift resets its earnings.
|
|
219
|
+
const last = 1439;
|
|
220
|
+
for (let minute = 0; minute <= last; minute++) {
|
|
221
|
+
const result = calculate({ ...base, ...patch }, start + minute * 60000);
|
|
222
|
+
assert.ok(result.earnedCents >= previous, `${label} minute ${minute} regressed`);
|
|
223
|
+
assert.ok(result.earnedCents <= result.dailyCents);
|
|
224
|
+
assert.ok(result.progress >= 0 && result.progress <= 1);
|
|
225
|
+
assert.equal(Number.isInteger(result.earnedCents), true);
|
|
226
|
+
previous = result.earnedCents;
|
|
227
|
+
}
|
|
228
|
+
assert.equal(previous, calculate({ ...base, ...patch }, start).dailyCents);
|
|
229
|
+
});
|
|
190
230
|
test('invalid empty shift or external break fails explicitly', () => {
|
|
191
231
|
assert.throws(() => at('2026-11-02T10:00:00Z', { start: '09:00', end: '09:00' }), RangeError);
|
|
192
|
-
assert.throws(
|
|
232
|
+
assert.throws(
|
|
233
|
+
() => at('2026-11-02T10:00:00Z', { breakStart: '18:00', breakEnd: '19:00' }),
|
|
234
|
+
RangeError,
|
|
235
|
+
);
|
|
193
236
|
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { companionPresentation, COMPANION_MESSAGES } from '../companion.js';
|
|
4
|
+
import { normalizeView } from '../ui-utils.js';
|
|
5
|
+
const show = (patch = {}) =>
|
|
6
|
+
companionPresentation({ workStatus: 'working', phase: 'countdown', ...patch });
|
|
7
|
+
const drink = { kind: 'drink', key: 'waterRecorded', token: 12 };
|
|
8
|
+
|
|
9
|
+
test('only a leased due reminder changes the character to a drinking pose; it adds no second message', () => {
|
|
10
|
+
const elsewhere = show({ phase: 'due', ownsPrompt: false });
|
|
11
|
+
assert.equal(elsewhere.pose, 'calm');
|
|
12
|
+
assert.equal(elsewhere.announce, false);
|
|
13
|
+
const here = show({ phase: 'due', ownsPrompt: true });
|
|
14
|
+
assert.equal(here.pose, 'drink');
|
|
15
|
+
assert.equal(here.message, null);
|
|
16
|
+
assert.equal(here.announce, false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('explicit drink feedback is acknowledged once; quiet and break states never celebrate or animate', () => {
|
|
20
|
+
const happy = show({ feedback: drink });
|
|
21
|
+
assert.equal(happy.pose, 'happy');
|
|
22
|
+
assert.equal(happy.animate, true);
|
|
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
|
+
]) {
|
|
32
|
+
const result = show({ ...patch, feedback: drink });
|
|
33
|
+
assert.equal(result.animate, false);
|
|
34
|
+
assert.equal(result.message, 'waterRecorded');
|
|
35
|
+
}
|
|
36
|
+
assert.equal(show({ phase: 'quiet', feedback: drink }).pose, 'quiet');
|
|
37
|
+
assert.equal(show({ workStatus: 'lunch', feedback: drink }).pose, 'rest');
|
|
38
|
+
assert.equal(show({ feedback: { ...drink, kind: 'notice' } }).animate, false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('hidden character, hidden page, reduced animation preference and unavailable state suppress reactions', () => {
|
|
42
|
+
const hidden = show({ visible: false, feedback: drink });
|
|
43
|
+
assert.equal(hidden.visible, false);
|
|
44
|
+
assert.equal(hidden.message, null);
|
|
45
|
+
assert.equal(hidden.announce, false);
|
|
46
|
+
assert.equal(show({ pageVisible: false, feedback: drink }).animate, false);
|
|
47
|
+
assert.equal(show({ motion: false, feedback: drink }).animate, false);
|
|
48
|
+
const failed = show({ failed: true, feedback: drink });
|
|
49
|
+
assert.equal(failed.message, 'companionUnavailable');
|
|
50
|
+
assert.equal(failed.announce, false);
|
|
51
|
+
assert.equal(failed.animate, false);
|
|
52
|
+
assert.equal(show({ ready: false, feedback: drink }).message, 'companionLoading');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('quiet, paused and work states have stable localized messages with no automatic announcements', () => {
|
|
56
|
+
const cases = [
|
|
57
|
+
{ phase: 'quiet' },
|
|
58
|
+
{ phase: 'paused' },
|
|
59
|
+
{ workStatus: 'lunch' },
|
|
60
|
+
{ workStatus: 'rest' },
|
|
61
|
+
{ workStatus: 'done' },
|
|
62
|
+
{ workStatus: 'before' },
|
|
63
|
+
{ phase: 'off' },
|
|
64
|
+
{},
|
|
65
|
+
];
|
|
66
|
+
for (const input of cases) {
|
|
67
|
+
const result = show(input);
|
|
68
|
+
assert.equal(result.announce, false);
|
|
69
|
+
assert.equal(result.animate, false);
|
|
70
|
+
for (const lang of ['zh', 'en']) assert.ok(COMPANION_MESSAGES[lang][result.message]);
|
|
71
|
+
assert.deepEqual(show(input), result);
|
|
72
|
+
}
|
|
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');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('existing view data keeps its position, privacy and opacity while explicit companion opt-out survives reload', () => {
|
|
79
|
+
const old = { mini: false, masked: true, opacity: 0.6, position: { x: 34, y: 56 } };
|
|
80
|
+
const migrated = normalizeView(old);
|
|
81
|
+
for (const key of Object.keys(old)) assert.deepEqual(migrated[key], old[key]);
|
|
82
|
+
assert.equal(migrated.companion, true);
|
|
83
|
+
const off = { ...migrated, companion: false, companionMotion: false };
|
|
84
|
+
assert.deepEqual(normalizeView(JSON.parse(JSON.stringify(off))), off);
|
|
85
|
+
assert.equal(normalizeView({ ...off, companion: true }).companionMotion, false);
|
|
86
|
+
});
|
package/tests/host.test.mjs
CHANGED
|
@@ -22,9 +22,11 @@ async function fixture(t) {
|
|
|
22
22
|
return { dir, file, operate: createOperations(file) };
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
function valid(patch) {
|
|
25
|
+
function valid(patch) {
|
|
26
|
+
return validateSettings({ ...DEFAULT_SETTINGS, ...patch });
|
|
27
|
+
}
|
|
26
28
|
|
|
27
|
-
test('defaults remain unsaved; real writes persist privately and can be reopened', async t => {
|
|
29
|
+
test('defaults remain unsaved; real writes persist privately and can be reopened', async (t) => {
|
|
28
30
|
const { dir, file, operate } = await fixture(t);
|
|
29
31
|
const initial = await operate('get');
|
|
30
32
|
assert.deepEqual(initial, { ok: true, settings: DEFAULT_SETTINGS, configured: false });
|
|
@@ -40,22 +42,32 @@ test('defaults remain unsaved; real writes persist privately and can be reopened
|
|
|
40
42
|
assert.deepEqual(await readdir(dir), ['data.json']);
|
|
41
43
|
});
|
|
42
44
|
|
|
43
|
-
test('legacy configuration gains only new defaults, without rewriting on get', async t => {
|
|
45
|
+
test('legacy configuration gains only new defaults, without rewriting on get', async (t) => {
|
|
44
46
|
const { file, operate } = await fixture(t);
|
|
45
47
|
const { payMode, dailySalary, ...legacy } = DEFAULT_SETTINGS;
|
|
46
48
|
legacy.monthlySalary = 12345;
|
|
47
49
|
const bytes = JSON.stringify(legacy);
|
|
48
50
|
await writeFile(file, bytes);
|
|
49
51
|
const migrated = await operate('get');
|
|
50
|
-
assert.deepEqual(migrated, {
|
|
52
|
+
assert.deepEqual(migrated, {
|
|
53
|
+
ok: true,
|
|
54
|
+
configured: true,
|
|
55
|
+
settings: { ...DEFAULT_SETTINGS, monthlySalary: 12345 },
|
|
56
|
+
});
|
|
51
57
|
assert.equal(await readFile(file, 'utf8'), bytes);
|
|
52
58
|
assert.equal((await operate('set', { dailySalary: 700 })).settings.monthlySalary, 12345);
|
|
53
59
|
assert.equal(JSON.parse(await readFile(file, 'utf8')).payMode, 'monthly');
|
|
54
60
|
});
|
|
55
61
|
|
|
56
|
-
test('malformed, unknown and incomplete saved data are never overwritten', async t => {
|
|
62
|
+
test('malformed, unknown and incomplete saved data are never overwritten', async (t) => {
|
|
57
63
|
const { file, operate, dir } = await fixture(t);
|
|
58
|
-
for (const bytes of [
|
|
64
|
+
for (const bytes of [
|
|
65
|
+
'{broken',
|
|
66
|
+
'null',
|
|
67
|
+
'[]',
|
|
68
|
+
JSON.stringify({ ...DEFAULT_SETTINGS, surprise: true }),
|
|
69
|
+
JSON.stringify({ start: '09:00' }),
|
|
70
|
+
]) {
|
|
59
71
|
await writeFile(file, bytes);
|
|
60
72
|
assert.equal((await operate('get')).ok, false);
|
|
61
73
|
assert.equal((await operate('set', { monthlySalary: 5 })).ok, false);
|
|
@@ -72,7 +84,8 @@ test('day and overnight shifts normalize breaks against shift start', () => {
|
|
|
72
84
|
{ start: '22:00', end: '06:00', breakStart: '05:00', breakEnd: '06:00' },
|
|
73
85
|
{ start: '22:00', end: '06:00', breakStart: '12:00', breakEnd: '12:00' },
|
|
74
86
|
{ breakStart: '00:00', breakEnd: '00:00' },
|
|
75
|
-
])
|
|
87
|
+
])
|
|
88
|
+
assert.doesNotThrow(() => valid(patch));
|
|
76
89
|
for (const patch of [
|
|
77
90
|
{ start: '09:00', end: '09:00' },
|
|
78
91
|
{ start: '22:00', end: '06:00' },
|
|
@@ -80,30 +93,49 @@ test('day and overnight shifts normalize breaks against shift start', () => {
|
|
|
80
93
|
{ start: '22:00', end: '06:00', breakStart: '22:00', breakEnd: '06:00' },
|
|
81
94
|
{ breakStart: '18:00', breakEnd: '19:00' },
|
|
82
95
|
{ breakStart: '12:00', breakEnd: '11:00' },
|
|
83
|
-
{ payMode: 'hourly' },
|
|
84
|
-
{
|
|
85
|
-
{
|
|
86
|
-
|
|
96
|
+
{ payMode: 'hourly' },
|
|
97
|
+
{ dailySalary: -1 },
|
|
98
|
+
{ dailySalary: Infinity },
|
|
99
|
+
{ monthlySalary: NaN },
|
|
100
|
+
{ workdaysPerMonth: 0 },
|
|
101
|
+
{ start: '9:00' },
|
|
102
|
+
{ weekdays: [1, 1] },
|
|
103
|
+
{ timezone: 'not/a-zone' },
|
|
104
|
+
{ surprise: true },
|
|
105
|
+
])
|
|
106
|
+
assert.throws(() => valid(patch), JSON.stringify(patch));
|
|
87
107
|
});
|
|
88
108
|
|
|
89
|
-
test('invalid patches and actions do not create files; queue recovers', async t => {
|
|
109
|
+
test('invalid patches and actions do not create files; queue recovers', async (t) => {
|
|
90
110
|
const { dir, operate } = await fixture(t);
|
|
91
|
-
for (const [action, patch] of [
|
|
111
|
+
for (const [action, patch] of [
|
|
112
|
+
['wat'],
|
|
113
|
+
['get', {}],
|
|
114
|
+
['set'],
|
|
115
|
+
['set', []],
|
|
116
|
+
['set', { unknown: 1 }],
|
|
117
|
+
['set', { payMode: 'weekly' }],
|
|
118
|
+
]) {
|
|
92
119
|
assert.equal((await operate(action, patch)).ok, false);
|
|
93
120
|
}
|
|
94
121
|
assert.deepEqual(await readdir(dir), []);
|
|
95
122
|
assert.equal((await operate('set', {})).configured, true);
|
|
96
123
|
});
|
|
97
124
|
|
|
98
|
-
test('concurrent partial sets serialize and preserve preceding updates', async t => {
|
|
125
|
+
test('concurrent partial sets serialize and preserve preceding updates', async (t) => {
|
|
99
126
|
const { file, operate, dir } = await fixture(t);
|
|
100
|
-
const patches = [
|
|
101
|
-
|
|
127
|
+
const patches = [
|
|
128
|
+
{ monthlySalary: 22222 },
|
|
129
|
+
{ dailySalary: 999 },
|
|
130
|
+
{ payMode: 'daily' },
|
|
131
|
+
{ timezone: 'UTC' },
|
|
132
|
+
];
|
|
133
|
+
const pending = patches.map((patch) => operate('set', patch));
|
|
102
134
|
pending.push(operate('set', { unknown: true }));
|
|
103
135
|
for (let i = 0; i < 30; i++) pending.push(operate('set', { dailySalary: 1000 + i }));
|
|
104
136
|
const results = await Promise.all(pending);
|
|
105
137
|
assert.equal(results[4].ok, false);
|
|
106
|
-
assert.ok(results.filter((_, i) => i !== 4).every(result => result.ok));
|
|
138
|
+
assert.ok(results.filter((_, i) => i !== 4).every((result) => result.ok));
|
|
107
139
|
const expected = { ...DEFAULT_SETTINGS, ...Object.assign({}, ...patches), dailySalary: 1029 };
|
|
108
140
|
assert.deepEqual(JSON.parse(await readFile(file, 'utf8')), expected);
|
|
109
141
|
assert.deepEqual((await operate('get')).settings, expected);
|
|
@@ -120,11 +152,21 @@ async function adapters(t) {
|
|
|
120
152
|
let command;
|
|
121
153
|
let tool;
|
|
122
154
|
module.apply({
|
|
123
|
-
effect: effect => effect(),
|
|
124
|
-
commands: {
|
|
125
|
-
|
|
155
|
+
effect: (effect) => effect(),
|
|
156
|
+
commands: {
|
|
157
|
+
register(definition) {
|
|
158
|
+
command = definition;
|
|
159
|
+
return () => {};
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
tools: {
|
|
163
|
+
register(definition) {
|
|
164
|
+
tool = definition;
|
|
165
|
+
return () => {};
|
|
166
|
+
},
|
|
167
|
+
},
|
|
126
168
|
});
|
|
127
|
-
const runCommand = async rawInput => {
|
|
169
|
+
const runCommand = async (rawInput) => {
|
|
128
170
|
const response = await command.handler({ rawInput });
|
|
129
171
|
const result = JSON.parse(response.text);
|
|
130
172
|
assert.equal(response.kind, result.ok ? 'success' : 'error');
|
|
@@ -133,7 +175,7 @@ async function adapters(t) {
|
|
|
133
175
|
return { ...fixtureData, tool, command, runCommand };
|
|
134
176
|
}
|
|
135
177
|
|
|
136
|
-
test('command and tool share results, validation, storage and a write queue', async t => {
|
|
178
|
+
test('command and tool share results, validation, storage and a write queue', async (t) => {
|
|
137
179
|
const { tool, command, runCommand, file } = await adapters(t);
|
|
138
180
|
assert.equal(command.recordInput, false);
|
|
139
181
|
assert.deepEqual(await runCommand('get'), await tool.execute({ action: 'get' }));
|
|
@@ -141,7 +183,10 @@ test('command and tool share results, validation, storage and a write queue', as
|
|
|
141
183
|
assert.deepEqual(await tool.execute({ action: 'get' }), written);
|
|
142
184
|
const second = await tool.execute({ action: 'set', settings: { monthlySalary: 25000 } });
|
|
143
185
|
assert.deepEqual(await runCommand('get'), second);
|
|
144
|
-
assert.deepEqual(
|
|
186
|
+
assert.deepEqual(
|
|
187
|
+
await runCommand('set {"unknown":1}'),
|
|
188
|
+
await tool.execute({ action: 'set', settings: { unknown: 1 } }),
|
|
189
|
+
);
|
|
145
190
|
await Promise.all([
|
|
146
191
|
runCommand('set {"dailySalary":750}'),
|
|
147
192
|
tool.execute({ action: 'set', settings: { timezone: 'UTC' } }),
|
|
@@ -149,11 +194,13 @@ test('command and tool share results, validation, storage and a write queue', as
|
|
|
149
194
|
const stored = JSON.parse(await readFile(file, 'utf8'));
|
|
150
195
|
assert.equal(stored.dailySalary, 750);
|
|
151
196
|
assert.equal(stored.timezone, 'UTC');
|
|
152
|
-
for (const raw of ['get {}', 'set', 'set {bad', 'wat'])
|
|
153
|
-
|
|
197
|
+
for (const raw of ['get {}', 'set', 'set {bad', 'wat'])
|
|
198
|
+
assert.equal((await runCommand(raw)).ok, false);
|
|
199
|
+
for (const args of [null, [], { action: 'get', unknown: true }])
|
|
200
|
+
assert.equal((await tool.execute(args)).ok, false);
|
|
154
201
|
});
|
|
155
202
|
|
|
156
|
-
test('tool schemas expose new fields, configured flag, and typed literal branches', async t => {
|
|
203
|
+
test('tool schemas expose new fields, configured flag, and typed literal branches', async (t) => {
|
|
157
204
|
const { tool } = await adapters(t);
|
|
158
205
|
const input = tool.parameters.properties.settings;
|
|
159
206
|
assert.equal(input.additionalProperties, false);
|
|
@@ -168,5 +215,7 @@ test('tool schemas expose new fields, configured flag, and typed literal branche
|
|
|
168
215
|
assert.equal(success.additionalProperties, false);
|
|
169
216
|
assert.equal(failure.additionalProperties, false);
|
|
170
217
|
const result = await tool.execute({ action: 'get' });
|
|
171
|
-
assert.deepEqual(tool.output.render({}, result), [
|
|
218
|
+
assert.deepEqual(tool.output.render({}, result), [
|
|
219
|
+
{ type: 'text', text: JSON.stringify(result) },
|
|
220
|
+
]);
|
|
172
221
|
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { calculate } from '../clock.js';
|
|
4
|
+
import {
|
|
5
|
+
defaultHydration,
|
|
6
|
+
validateHydration,
|
|
7
|
+
hydrationContext,
|
|
8
|
+
hydrationView,
|
|
9
|
+
transitionHydration,
|
|
10
|
+
HYDRATION_ACTION_LIMIT,
|
|
11
|
+
} from '../hydration.js';
|
|
12
|
+
|
|
13
|
+
const settings = {
|
|
14
|
+
payMode: 'monthly',
|
|
15
|
+
monthlySalary: 12000,
|
|
16
|
+
workdaysPerMonth: 20,
|
|
17
|
+
start: '09:00',
|
|
18
|
+
end: '18:00',
|
|
19
|
+
breakStart: '12:00',
|
|
20
|
+
breakEnd: '13:00',
|
|
21
|
+
weekdays: [1, 2, 3, 4, 5],
|
|
22
|
+
timezone: 'Asia/Shanghai',
|
|
23
|
+
};
|
|
24
|
+
const 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);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
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' });
|
|
78
|
+
const eventId = view(initial, '10:00').eventId;
|
|
79
|
+
const actions = [manual('10:00', 'due', {}, { eventId }), { type: 'drink', eventId }];
|
|
80
|
+
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);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('manual request IDs are bounded, distinct records count, and old-shift retries cannot count tomorrow', () => {
|
|
89
|
+
let state = enabled();
|
|
90
|
+
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');
|
|
135
|
+
}
|
|
136
|
+
});
|
|
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', () => {
|
|
212
|
+
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));
|
|
226
|
+
});
|