dsh-offwork 1.0.0 → 1.0.1

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.
@@ -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, workdaysPerMonth: 20,
7
- start: '09:00', end: '18:00', breakStart: '12:00', breakEnd: '13:00',
8
- weekdays: [1, 2, 3, 4, 5], timezone: 'Asia/Shanghai',
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], ['09:00', 'working', 0], ['11:00', 'working', 0.25],
16
- ['12:00', 'lunch', 0.375], ['12:59', 'lunch', 0.375],
17
- ['13:00', 'working', 0.375], ['17:00', 'working', 0.875], ['18:00', 'done', 1], ['23:59', 'done', 1],
18
- ]) test(`same-day boundary ${time}: ${status}`, () => {
19
- const result = at(`2026-11-02T${time}:00+08:00`);
20
- assert.equal(result.status, status);
21
- assert.equal(result.progress, progress);
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', { ...night, breakStart: '23:30', breakEnd: '00:30' });
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', dailySalary: 123.459, monthlySalary: undefined, workdaysPerMonth: undefined,
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 = { ...night, timezone: 'America/New_York', weekdays: [6], breakStart: '00:00', breakEnd: '00:00' };
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 = { ...night, timezone: 'America/New_York', weekdays: [6], breakStart: '00:00', breakEnd: '00:00' };
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
- ['daily no lunch', { payMode: 'daily', dailySalary: 109.99, breakStart: '00:00', breakEnd: '00:00' }, '2026-11-02T00:00:00+08:00'],
173
- ['night cross-midnight lunch', { ...night, breakStart: '23:30', breakEnd: '00:30' }, '2026-11-02T22:00:00+08:00'],
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
- ]) test(`whole-day minute grid: monotone and bounded (${label})`, () => {
176
- let previous = 0;
177
- const start = Date.parse(begin);
178
- // Stop before the next civil day / shift resets its earnings.
179
- const last = 1439;
180
- for (let minute = 0; minute <= last; minute++) {
181
- const result = calculate({ ...base, ...patch }, start + minute * 60000);
182
- assert.ok(result.earnedCents >= previous, `${label} minute ${minute} regressed`);
183
- assert.ok(result.earnedCents <= result.dailyCents);
184
- assert.ok(result.progress >= 0 && result.progress <= 1);
185
- assert.equal(Number.isInteger(result.earnedCents), true);
186
- previous = result.earnedCents;
187
- }
188
- assert.equal(previous, calculate({ ...base, ...patch }, start).dailyCents);
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(() => at('2026-11-02T10:00:00Z', { breakStart: '18:00', breakEnd: '19:00' }), RangeError);
232
+ assert.throws(
233
+ () => at('2026-11-02T10:00:00Z', { breakStart: '18:00', breakEnd: '19:00' }),
234
+ RangeError,
235
+ );
193
236
  });
@@ -22,9 +22,11 @@ async function fixture(t) {
22
22
  return { dir, file, operate: createOperations(file) };
23
23
  }
24
24
 
25
- function valid(patch) { return validateSettings({ ...DEFAULT_SETTINGS, ...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, { ok: true, configured: true, settings: { ...DEFAULT_SETTINGS, monthlySalary: 12345 } });
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 ['{broken', 'null', '[]', JSON.stringify({ ...DEFAULT_SETTINGS, surprise: true }), JSON.stringify({ start: '09:00' })]) {
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
- ]) assert.doesNotThrow(() => valid(patch));
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' }, { dailySalary: -1 }, { dailySalary: Infinity },
84
- { monthlySalary: NaN }, { workdaysPerMonth: 0 }, { start: '9:00' },
85
- { weekdays: [1, 1] }, { timezone: 'not/a-zone' }, { surprise: true },
86
- ]) assert.throws(() => valid(patch), JSON.stringify(patch));
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 [['wat'], ['get', {}], ['set'], ['set', []], ['set', { unknown: 1 }], ['set', { payMode: 'weekly' }]]) {
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 = [{ monthlySalary: 22222 }, { dailySalary: 999 }, { payMode: 'daily' }, { timezone: 'UTC' }];
101
- const pending = patches.map(patch => operate('set', patch));
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: { register(definition) { command = definition; return () => {}; } },
125
- tools: { register(definition) { tool = definition; return () => {}; } },
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(await runCommand('set {"unknown":1}'), await tool.execute({ action: 'set', settings: { unknown: 1 } }));
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']) assert.equal((await runCommand(raw)).ok, false);
153
- for (const args of [null, [], { action: 'get', unknown: true }]) assert.equal((await tool.execute(args)).ok, false);
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), [{ type: 'text', text: JSON.stringify(result) }]);
218
+ assert.deepEqual(tool.output.render({}, result), [
219
+ { type: 'text', text: JSON.stringify(result) },
220
+ ]);
172
221
  });
package/tests/ui.test.mjs CHANGED
@@ -1,25 +1,53 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { readFile } from 'node:fs/promises';
4
- import { formatDuration, clampPosition, normalizeView, clampOpacity, decodeCommand, changedSettings } from '../ui-utils.js';
4
+ import {
5
+ formatDuration,
6
+ clampPosition,
7
+ normalizeView,
8
+ clampOpacity,
9
+ decodeCommand,
10
+ changedSettings,
11
+ } from '../ui-utils.js';
5
12
  test('duration rounds countdown upward and supports >24 hours', () => {
6
- assert.equal(formatDuration(0), '00:00:00'); assert.equal(formatDuration(3661), '01:01:01');
7
- assert.equal(formatDuration(.1), '00:00:01'); assert.equal(formatDuration(90000), '25:00:00');
8
- assert.equal(formatDuration(NaN), '--:--:--'); assert.equal(formatDuration(-1), '--:--:--');
13
+ assert.equal(formatDuration(0), '00:00:00');
14
+ assert.equal(formatDuration(3661), '01:01:01');
15
+ assert.equal(formatDuration(0.1), '00:00:01');
16
+ assert.equal(formatDuration(90000), '25:00:00');
17
+ assert.equal(formatDuration(NaN), '--:--:--');
18
+ assert.equal(formatDuration(-1), '--:--:--');
9
19
  });
10
20
  test('position stays visible when viewport shrinks', () => {
11
- assert.deepEqual(clampPosition({ x: 9000, y: -20 }, { width: 800, height: 600 }, { width: 330, height: 400 }), { x: 458, y: 12 });
12
- assert.deepEqual(clampPosition(null, { width: 200, height: 200 }, { width: 330, height: 400 }), { x: 12, y: 12 });
21
+ assert.deepEqual(
22
+ clampPosition({ x: 9000, y: -20 }, { width: 800, height: 600 }, { width: 330, height: 400 }),
23
+ { x: 458, y: 12 },
24
+ );
25
+ assert.deepEqual(clampPosition(null, { width: 200, height: 200 }, { width: 330, height: 400 }), {
26
+ x: 12,
27
+ y: 12,
28
+ });
13
29
  });
14
30
  test('view defaults: compact, salary hidden, 82% glass', () => {
15
- assert.deepEqual(normalizeView(null), { mini: true, masked: true, opacity: 0.82, position: null });
16
- assert.deepEqual(normalizeView({ mini: false, masked: false, opacity: 0.5, position: { x: 1, y: 2 } }),
17
- { mini: false, masked: false, opacity: 0.5, position: { x: 1, y: 2 } });
31
+ assert.deepEqual(normalizeView(null), {
32
+ mini: true,
33
+ masked: true,
34
+ opacity: 0.82,
35
+ position: null,
36
+ });
37
+ assert.deepEqual(
38
+ normalizeView({ mini: false, masked: false, opacity: 0.5, position: { x: 1, y: 2 } }),
39
+ { mini: false, masked: false, opacity: 0.5, position: { x: 1, y: 2 } },
40
+ );
18
41
  assert.equal(normalizeView({ mini: 'yes' }).mini, true);
19
42
  assert.equal(normalizeView({ masked: 'false' }).masked, true);
20
43
  });
21
44
  test('malformed positions and opacity are repaired, not trusted', () => {
22
- assert.deepEqual(normalizeView({ mini: 'yes', masked: true, position: { x: 'oops', y: 2 } }), { mini: true, masked: true, opacity: 0.82, position: null });
45
+ assert.deepEqual(normalizeView({ mini: 'yes', masked: true, position: { x: 'oops', y: 2 } }), {
46
+ mini: true,
47
+ masked: true,
48
+ opacity: 0.82,
49
+ position: null,
50
+ });
23
51
  assert.equal(normalizeView({ opacity: 0 }).opacity, 0.3);
24
52
  assert.equal(normalizeView({ opacity: 9 }).opacity, 1);
25
53
  assert.equal(normalizeView({ opacity: 'x' }).opacity, 0.82);
@@ -31,25 +59,52 @@ test('salary stays hidden until explicitly revealed, including on first paint',
31
59
  assert.equal(normalizeView({ masked: false }).masked, false);
32
60
  });
33
61
  test('RPC decodes every failure layer and real command envelopes', () => {
34
- const response = (data, kind = 'success') => ({ ok: true, value: { result: { kind, text: JSON.stringify(data) } } });
35
- assert.deepEqual(decodeCommand(response({ ok: true, settings: { monthlySalary: 1000 }, configured: true })).settings, { monthlySalary: 1000 });
36
- assert.throws(() => decodeCommand({ ok: false, error: { message: 'permission denied' } }), /permission denied/);
62
+ const response = (data, kind = 'success') => ({
63
+ ok: true,
64
+ value: { result: { kind, text: JSON.stringify(data) } },
65
+ });
66
+ assert.deepEqual(
67
+ decodeCommand(response({ ok: true, settings: { monthlySalary: 1000 }, configured: true }))
68
+ .settings,
69
+ { monthlySalary: 1000 },
70
+ );
71
+ assert.throws(
72
+ () => decodeCommand({ ok: false, error: { message: 'permission denied' } }),
73
+ /permission denied/,
74
+ );
37
75
  assert.throws(() => decodeCommand({ ok: true }), /Command unavailable/);
38
- assert.throws(() => decodeCommand(response({ ok: false, error: 'bad shift' }, 'error')), /bad shift/);
39
- assert.throws(() => decodeCommand({ ok: true, value: { result: { kind: 'success', text: 'oops' } } }), /Invalid/);
76
+ assert.throws(
77
+ () => decodeCommand(response({ ok: false, error: 'bad shift' }, 'error')),
78
+ /bad shift/,
79
+ );
80
+ assert.throws(
81
+ () => decodeCommand({ ok: true, value: { result: { kind: 'success', text: 'oops' } } }),
82
+ /Invalid/,
83
+ );
40
84
  });
41
85
  test('partial saves preserve unrelated edits from another caller', () => {
42
- assert.deepEqual(changedSettings({ salary: 1, days: [1, 2], start: '09:00' }, { salary: 2, days: [1, 2], start: '09:00' }), { salary: 2 });
86
+ assert.deepEqual(
87
+ changedSettings(
88
+ { salary: 1, days: [1, 2], start: '09:00' },
89
+ { salary: 2, days: [1, 2], start: '09:00' },
90
+ ),
91
+ { salary: 2 },
92
+ );
43
93
  });
44
94
  test('Client uses the exact tested engine and a single surface', async () => {
45
95
  const source = await readFile(new URL('../client.source.js', import.meta.url), 'utf8');
46
96
  const built = await readFile(new URL('../client.js', import.meta.url), 'utf8');
47
- const engine = (await readFile(new URL('../clock.js', import.meta.url), 'utf8')).replace(/^export /gm, '');
97
+ const engine = (await readFile(new URL('../clock.js', import.meta.url), 'utf8')).replace(
98
+ /^export /gm,
99
+ '',
100
+ );
48
101
  assert.ok(built.includes(engine));
49
102
  assert.match(source, /prefers-reduced-motion:reduce/);
50
103
  assert.match(source, /prefers-reduced-transparency:reduce/);
51
- assert.match(source, /visibilitychange/); assert.match(source, /removeEventListener\('storage'/);
52
- assert.match(source, /ro\?\.disconnect/); assert.match(source, /generation\.current/);
104
+ assert.match(source, /visibilitychange/);
105
+ assert.match(source, /removeEventListener\('storage'/);
106
+ assert.match(source, /ro\?\.disconnect/);
107
+ assert.match(source, /generation\.current/);
53
108
  assert.doesNotMatch(source, /document\.body|window\.open|fetch\(/);
54
109
  assert.match(source, /money\(safeState\.earned\)/);
55
110
  assert.match(source, /owc-glass/);
@@ -64,7 +119,9 @@ test('compact strip stacks time above amount', async () => {
64
119
  assert.match(css, /flex-direction:column/);
65
120
  assert.match(source, /className: 'owc-pill-top'/);
66
121
  assert.match(source, /className: 'owc-pill-bottom'/);
67
- assert.ok(source.indexOf("className: 'owc-pill-time'") < source.indexOf("className: 'owc-pill-money'"));
122
+ assert.ok(
123
+ source.indexOf("className: 'owc-pill-time'") < source.indexOf("className: 'owc-pill-money'"),
124
+ );
68
125
  });
69
126
  test('compact strip keeps both drag surface and expand action; panel keeps collapse', async () => {
70
127
  const source = await readFile(new URL('../client.source.js', import.meta.url), 'utf8');
package/tools/build.mjs CHANGED
@@ -1,13 +1,28 @@
1
1
  import { readFile, writeFile } from 'node:fs/promises';
2
+
2
3
  const root = new URL('../', import.meta.url);
3
- const [entry, clock, utils] = await Promise.all(['client.source.js','clock.js','ui-utils.js'].map(name=>readFile(new URL(name,root),'utf8')));
4
- if (entry.split('/* PURE_MODULES */').length !== 2) throw new Error('Expected exactly one pure-module marker');
5
- const pure = [clock,utils].map(text=>text.replace(/^export /gm,'')).join('\n');
6
- const result = '// Generated by tools/build.mjs; edit client.source.js, clock.js or ui-utils.js.\n'+entry.replace('/* PURE_MODULES */',pure);
7
- if(process.argv.includes('--check')) {
8
- if(await readFile(new URL('client.js',root),'utf8')!==result) throw new Error('Client artifact is stale');
4
+ const moduleNames = ['client.source.js', 'clock.js', 'ui-utils.js'];
5
+ const [entry, ...modules] = await Promise.all(
6
+ moduleNames.map((name) => readFile(new URL(name, root), 'utf8')),
7
+ );
8
+ const marker = '/* PURE_MODULES */';
9
+ if (entry.split(marker).length !== 2) {
10
+ throw new Error('Expected exactly one pure-module marker in client.source.js');
11
+ }
12
+
13
+ // Preserve the pure modules verbatim apart from exports so the browser runs
14
+ // the same implementation that the Node.js tests import.
15
+ const pure = modules.map((source) => source.replace(/^export /gm, '')).join('\n');
16
+ const banner = '// Generated by tools/build.mjs; edit client.source.js, clock.js or ui-utils.js.\n';
17
+ const result = banner + entry.replace(marker, pure);
18
+ const output = new URL('client.js', root);
19
+
20
+ if (process.argv.includes('--check')) {
21
+ if ((await readFile(output, 'utf8')) !== result) {
22
+ throw new Error('Client artifact is stale. Run npm run build and commit client.js.');
23
+ }
9
24
  console.log('Client artifact matches tested source');
10
25
  } else {
11
- await writeFile(new URL('client.js',root),result);
26
+ await writeFile(output, result);
12
27
  console.log('Built client.js from one calculation source');
13
28
  }
@@ -0,0 +1,96 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { readFile, readdir } from 'node:fs/promises';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const root = new URL('../', import.meta.url);
7
+ const cwd = fileURLToPath(root);
8
+ const read = (path) => readFile(new URL(path, root), 'utf8');
9
+ const pkg = JSON.parse(await read('package.json'));
10
+
11
+ assert.equal(pkg.name, 'dsh-offwork');
12
+ assert.equal(pkg.type, 'module');
13
+ assert.equal(pkg.license, 'MIT');
14
+ assert.equal(pkg.repository.url, 'git+https://github.com/SnowBeatRain/dsh-offwork.git');
15
+ assert.ok(pkg.engines?.dsh, 'Declare the supported Harness versions');
16
+ assert.equal(pkg.dsh?.bundle?.patch, './cordis.patch.yml');
17
+ assert.equal(pkg.dsh?.client?.platform, 'web');
18
+ assert.match(await read('LICENSE'), /^MIT License/);
19
+
20
+ // This check covers this project's patch; Harness composition remains the
21
+ // authoritative check that the bundle can be installed in a real profile.
22
+ const patch = await read('cordis.patch.yml');
23
+ assert.match(patch, /^- insert:/m);
24
+ assert.match(patch, /^\s+- id: offwork-clock\s*$/m);
25
+ assert.match(patch, /^\s+name: ['"]?dsh-offwork['"]?\s*$/m);
26
+
27
+ for (const lifecycle of ['preinstall', 'install', 'postinstall', 'prepare']) {
28
+ assert.equal(pkg.scripts?.[lifecycle], undefined, `Unexpected install script: ${lifecycle}`);
29
+ }
30
+ for (const name of Object.keys(pkg.dependencies ?? {})) {
31
+ assert.ok(!name.startsWith('@deepseek-ai/'), `${name} belongs in peerDependencies`);
32
+ }
33
+
34
+ const sourceFiles = [];
35
+ for (const directory of ['', 'tests/', 'tools/']) {
36
+ for (const entry of await readdir(new URL(directory || '.', root), { withFileTypes: true })) {
37
+ if (entry.isFile() && /\.(?:js|mjs)$/.test(entry.name)) {
38
+ sourceFiles.push(directory + entry.name);
39
+ }
40
+ }
41
+ }
42
+ for (const path of sourceFiles) {
43
+ execFileSync(process.execPath, ['--check', path], { cwd, stdio: 'pipe' });
44
+ }
45
+
46
+ for (const language of ['zh', 'en']) {
47
+ const locale = JSON.parse(await read(`locale/${language}.json`));
48
+ assert.equal(typeof locale.meta?.title, 'string');
49
+ assert.ok(locale.meta.title.length > 0);
50
+ assert.equal(typeof locale.meta?.description, 'string');
51
+ assert.ok(locale.meta.description.length > 0);
52
+ }
53
+
54
+ // Ignore lifecycle scripts here: this checker also runs inside prepublishOnly.
55
+ // Inspect npm's actual package file list, rather than relying on .gitignore.
56
+ const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
57
+ const [packed] = JSON.parse(
58
+ execFileSync(npm, ['pack', '--dry-run', '--json', '--ignore-scripts'], {
59
+ cwd,
60
+ encoding: 'utf8',
61
+ stdio: ['ignore', 'pipe', 'pipe'],
62
+ }),
63
+ );
64
+ const files = new Set(packed.files.map((file) => file.path));
65
+ assert.equal(packed.name, pkg.name);
66
+ assert.equal(packed.version, pkg.version);
67
+ for (const path of [
68
+ 'package.json',
69
+ 'index.js',
70
+ 'client.js',
71
+ 'cordis.patch.yml',
72
+ 'icon.svg',
73
+ 'locale/zh.json',
74
+ 'locale/en.json',
75
+ 'README.md',
76
+ 'LICENSE',
77
+ ]) {
78
+ assert.ok(files.has(path), `Missing package file: ${path}`);
79
+ }
80
+ for (const target of Object.values(pkg.exports)) {
81
+ if (typeof target === 'string' && !target.includes('*')) {
82
+ assert.ok(files.has(target.replace(/^\.\//, '')), `Missing export: ${target}`);
83
+ }
84
+ }
85
+ for (const path of files) {
86
+ assert.ok(
87
+ !/(^|\/)(?:data\.json|\.data-[^/]*|\.env(?:\.[^/]*)?|\.npmrc|\.git(?:\/|$)|node_modules(?:\/|$))/.test(
88
+ path,
89
+ ),
90
+ `Local settings or credentials included in package: ${path}`,
91
+ );
92
+ assert.ok(!path.endsWith('.tgz'), `Nested package archive: ${path}`);
93
+ }
94
+ console.log(
95
+ `Package checks passed: ${sourceFiles.length} JavaScript files, ${files.size} packed files.`,
96
+ );