dsh-offwork 0.0.0-stage → 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.
@@ -0,0 +1,236 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { calculate } from '../clock.js';
4
+
5
+ const base = {
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',
14
+ };
15
+ const at = (time, patch = {}) => calculate({ ...base, ...patch }, Date.parse(time));
16
+ const night = { start: '22:00', end: '06:00', breakStart: '02:00', breakEnd: '03:00' };
17
+ const utc = { timezone: 'UTC' };
18
+
19
+ for (const [time, status, progress] of [
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
+ });
35
+
36
+ test('before has both actual end countdown and start countdown', () => {
37
+ const result = at('2026-11-02T08:00:00+08:00');
38
+ assert.equal(result.remaining, 36000);
39
+ assert.equal(result.startRemaining, 3600);
40
+ });
41
+ test('lunch freezes pay but not the real end countdown', () => {
42
+ const first = at('2026-11-02T12:00:00+08:00');
43
+ const second = at('2026-11-02T12:30:00+08:00');
44
+ assert.equal(first.earned, second.earned);
45
+ assert.equal(first.remaining - second.remaining, 1800);
46
+ assert.equal(second.startRemaining, 0);
47
+ });
48
+ test('equal break endpoints mean no break even outside the shift', () => {
49
+ const result = at('2026-11-02T13:30:00+08:00', { breakStart: '00:00', breakEnd: '00:00' });
50
+ assert.equal(result.progress, 0.5);
51
+ assert.equal(result.total, 9 * 3600);
52
+ assert.equal(result.breakStart, null);
53
+ });
54
+ test('omitted break is optional', () => {
55
+ const result = at('2026-11-02T13:30:00+08:00', { breakStart: undefined, breakEnd: undefined });
56
+ assert.equal(result.progress, 0.5);
57
+ });
58
+ test('weekend is rest, not a completed salaried shift', () => {
59
+ const result = at('2026-11-07T20:00:00+08:00');
60
+ assert.equal(result.status, 'rest');
61
+ assert.equal(result.earned, 0);
62
+ assert.equal(result.progress, 0);
63
+ assert.equal(result.remaining, 0);
64
+ });
65
+ test('night early hours belong to previous start date', () => {
66
+ const result = at('2026-11-03T01:00:00+08:00', night);
67
+ assert.equal(result.shiftDate, '2026-11-02');
68
+ assert.equal(result.date, '2026-11-03');
69
+ assert.equal(result.status, 'working');
70
+ assert.equal(result.elapsed, 3 * 3600);
71
+ assert.equal(result.remaining, 5 * 3600);
72
+ });
73
+ test('night break after midnight is normalized into the shift', () => {
74
+ const result = at('2026-11-03T02:30:00+08:00', night);
75
+ assert.equal(result.status, 'lunch');
76
+ assert.equal(result.elapsed, 4 * 3600);
77
+ assert.equal(result.total, 7 * 3600);
78
+ });
79
+ test('break spanning midnight is normalized', () => {
80
+ const result = at('2026-11-03T00:15:00+08:00', {
81
+ ...night,
82
+ breakStart: '23:30',
83
+ breakEnd: '00:30',
84
+ });
85
+ assert.equal(result.status, 'lunch');
86
+ assert.equal(result.elapsed, 1.5 * 3600);
87
+ });
88
+ test('Saturday early night shift uses working Friday', () => {
89
+ const result = at('2026-11-07T04:00:00+08:00', night);
90
+ assert.equal(result.status, 'working');
91
+ assert.equal(result.shiftDate, '2026-11-06');
92
+ });
93
+ test('night completed status retained through the following daytime', () => {
94
+ for (const time of ['06:00', '12:00', '21:59']) {
95
+ const result = at(`2026-11-03T${time}:00+08:00`, night);
96
+ assert.equal(result.status, 'done');
97
+ assert.equal(result.shiftDate, '2026-11-02');
98
+ assert.equal(result.earned, result.daily);
99
+ }
100
+ });
101
+ test('new night shift starts exactly at 22:00', () => {
102
+ const result = at('2026-11-03T22:00:00+08:00', night);
103
+ assert.equal(result.status, 'working');
104
+ assert.equal(result.shiftDate, '2026-11-03');
105
+ assert.equal(result.progress, 0);
106
+ });
107
+ test('Monday early hours still belong to nonworking Sunday', () => {
108
+ assert.equal(at('2026-11-02T04:00:00+08:00', night).status, 'rest');
109
+ });
110
+ test('Monday daytime is before when Sunday did not work', () => {
111
+ for (const time of ['06:00', '12:00', '21:59']) {
112
+ const result = at(`2026-11-02T${time}:00+08:00`, night);
113
+ assert.equal(result.status, 'before');
114
+ assert.equal(result.shiftDate, '2026-11-02');
115
+ assert.ok(result.startRemaining > 0);
116
+ }
117
+ });
118
+ test('Saturday daytime retains Friday done; Saturday night is rest', () => {
119
+ assert.equal(at('2026-11-07T12:00:00+08:00', night).status, 'done');
120
+ assert.equal(at('2026-11-07T22:00:00+08:00', night).status, 'rest');
121
+ });
122
+ test('configured Sunday workday overrides weekday assumptions', () => {
123
+ const result = at('2026-11-08T10:00:00+08:00', { weekdays: [0] });
124
+ assert.equal(result.status, 'working');
125
+ });
126
+ test('daily salary ignores monthly fields and truncates cents', () => {
127
+ const result = at('2026-11-02T18:00:00+08:00', {
128
+ payMode: 'daily',
129
+ dailySalary: 123.459,
130
+ monthlySalary: undefined,
131
+ workdaysPerMonth: undefined,
132
+ });
133
+ assert.equal(result.payMode, 'daily');
134
+ assert.equal(result.daily, 123.45);
135
+ assert.equal(result.earned, 123.45);
136
+ });
137
+ test('decimal division does not lose a cent at completion', () => {
138
+ const result = at('2026-11-02T18:00:00+08:00', { monthlySalary: 0.29, workdaysPerMonth: 1 });
139
+ assert.equal(result.dailyCents, 29);
140
+ assert.equal(result.earnedCents, 29);
141
+ });
142
+ test('hourly and partial salary truncate rather than round', () => {
143
+ const result = at('2026-11-02T10:00:00+08:00', { payMode: 'daily', dailySalary: 100.01 });
144
+ assert.equal(result.earned, 12.5);
145
+ assert.equal(result.hourly, 12.5);
146
+ });
147
+ test('zero salary remains finite', () => {
148
+ const result = at('2026-11-02T10:00:00+08:00', { monthlySalary: 0 });
149
+ assert.equal(result.earned, 0);
150
+ assert.equal(result.hourly, 0);
151
+ });
152
+ test('scientific notation salaries use exact decimal arithmetic', () => {
153
+ assert.equal(at('2026-11-02T18:00:00+08:00', { payMode: 'daily', dailySalary: 1e-7 }).daily, 0);
154
+ assert.equal(at('2026-11-02T18:00:00+08:00', { payMode: 'daily', dailySalary: 1e7 }).daily, 1e7);
155
+ });
156
+ test('absolute timestamp and configured timezone determine local date and status', () => {
157
+ const timestamp = '2026-11-02T01:00:00Z';
158
+ assert.equal(at(timestamp).status, 'working');
159
+ assert.equal(at(timestamp, utc).status, 'before');
160
+ const la = at(timestamp, { timezone: 'America/Los_Angeles' });
161
+ assert.equal(la.status, 'rest');
162
+ assert.equal(la.date, '2026-11-01');
163
+ });
164
+ test('fractional-zone offset is handled independently of system timezone', () => {
165
+ const result = at('2026-11-02T03:15:00Z', { timezone: 'Asia/Kathmandu' });
166
+ assert.equal(result.status, 'working');
167
+ assert.equal(result.progress, 0);
168
+ });
169
+ test('DST spring night countdown measures real elapsed seconds', () => {
170
+ const patch = {
171
+ ...night,
172
+ timezone: 'America/New_York',
173
+ weekdays: [6],
174
+ breakStart: '00:00',
175
+ breakEnd: '00:00',
176
+ };
177
+ const result = at('2026-03-08T06:00:00Z', patch);
178
+ assert.equal(result.shiftDate, '2026-03-07');
179
+ assert.equal(result.total, 7 * 3600);
180
+ assert.equal(result.elapsed, 3 * 3600);
181
+ assert.equal(result.remaining, 4 * 3600);
182
+ });
183
+ test('DST fall night countdown includes the repeated hour', () => {
184
+ const patch = {
185
+ ...night,
186
+ timezone: 'America/New_York',
187
+ weekdays: [6],
188
+ breakStart: '00:00',
189
+ breakEnd: '00:00',
190
+ };
191
+ const result = at('2026-11-01T06:30:00Z', patch);
192
+ assert.equal(result.total, 9 * 3600);
193
+ assert.equal(result.elapsed, 4.5 * 3600);
194
+ assert.equal(result.remaining, 4.5 * 3600);
195
+ });
196
+ test('subsecond countdown rounds upwards and salary remains truncated', () => {
197
+ const result = at('2026-11-02T17:59:59.999+08:00');
198
+ assert.equal(result.remaining, 1);
199
+ assert.equal(result.earned, 499.99);
200
+ });
201
+ for (const [label, patch, begin] of [
202
+ ['monthly lunch', {}, '2026-11-02T00: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
+ ],
213
+ ['decimal cents', { monthlySalary: 0.29, workdaysPerMonth: 1 }, '2026-11-02T00:00:00+08:00'],
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
+ });
230
+ test('invalid empty shift or external break fails explicitly', () => {
231
+ assert.throws(() => at('2026-11-02T10:00:00Z', { start: '09:00', end: '09:00' }), RangeError);
232
+ assert.throws(
233
+ () => at('2026-11-02T10:00:00Z', { breakStart: '18:00', breakEnd: '19:00' }),
234
+ RangeError,
235
+ );
236
+ });
@@ -0,0 +1,221 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, readFile, writeFile, stat, readdir, rm } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, resolve, dirname, basename } from 'node:path';
6
+ import { pathToFileURL } from 'node:url';
7
+ import { createOperations, DEFAULT_SETTINGS, validateSettings } from '../index.js';
8
+
9
+ const source = new URL('../index.js', import.meta.url);
10
+ const prefix = 'offwork-clock-host-';
11
+ async function fixture(t) {
12
+ const root = resolve(tmpdir());
13
+ const dir = await mkdtemp(join(root, prefix));
14
+ t.after(async () => {
15
+ // Never recursively remove anything except our unique direct temporary child.
16
+ assert.equal(dirname(resolve(dir)), root);
17
+ assert.ok(basename(dir).startsWith(prefix));
18
+ assert.notEqual(resolve(dir), root);
19
+ await rm(dir, { recursive: true, force: true });
20
+ });
21
+ const file = join(dir, 'data.json');
22
+ return { dir, file, operate: createOperations(file) };
23
+ }
24
+
25
+ function valid(patch) {
26
+ return validateSettings({ ...DEFAULT_SETTINGS, ...patch });
27
+ }
28
+
29
+ test('defaults remain unsaved; real writes persist privately and can be reopened', async (t) => {
30
+ const { dir, file, operate } = await fixture(t);
31
+ const initial = await operate('get');
32
+ assert.deepEqual(initial, { ok: true, settings: DEFAULT_SETTINGS, configured: false });
33
+ initial.settings.weekdays.push(6);
34
+ assert.deepEqual((await operate('get')).settings.weekdays, [1, 2, 3, 4, 5]);
35
+ assert.deepEqual(await readdir(dir), []);
36
+ const saved = await operate('set', { payMode: 'daily', dailySalary: 500 });
37
+ assert.equal(saved.ok, true);
38
+ assert.equal(saved.configured, true);
39
+ assert.deepEqual(JSON.parse(await readFile(file, 'utf8')), saved.settings);
40
+ assert.equal((await stat(file)).mode & 0o777, 0o600);
41
+ assert.deepEqual(await createOperations(pathToFileURL(file))('get'), saved);
42
+ assert.deepEqual(await readdir(dir), ['data.json']);
43
+ });
44
+
45
+ test('legacy configuration gains only new defaults, without rewriting on get', async (t) => {
46
+ const { file, operate } = await fixture(t);
47
+ const { payMode, dailySalary, ...legacy } = DEFAULT_SETTINGS;
48
+ legacy.monthlySalary = 12345;
49
+ const bytes = JSON.stringify(legacy);
50
+ await writeFile(file, bytes);
51
+ const migrated = await operate('get');
52
+ assert.deepEqual(migrated, {
53
+ ok: true,
54
+ configured: true,
55
+ settings: { ...DEFAULT_SETTINGS, monthlySalary: 12345 },
56
+ });
57
+ assert.equal(await readFile(file, 'utf8'), bytes);
58
+ assert.equal((await operate('set', { dailySalary: 700 })).settings.monthlySalary, 12345);
59
+ assert.equal(JSON.parse(await readFile(file, 'utf8')).payMode, 'monthly');
60
+ });
61
+
62
+ test('malformed, unknown and incomplete saved data are never overwritten', async (t) => {
63
+ const { file, operate, dir } = await fixture(t);
64
+ for (const bytes of [
65
+ '{broken',
66
+ 'null',
67
+ '[]',
68
+ JSON.stringify({ ...DEFAULT_SETTINGS, surprise: true }),
69
+ JSON.stringify({ start: '09:00' }),
70
+ ]) {
71
+ await writeFile(file, bytes);
72
+ assert.equal((await operate('get')).ok, false);
73
+ assert.equal((await operate('set', { monthlySalary: 5 })).ok, false);
74
+ assert.equal(await readFile(file, 'utf8'), bytes);
75
+ assert.deepEqual(await readdir(dir), ['data.json']);
76
+ }
77
+ });
78
+
79
+ test('day and overnight shifts normalize breaks against shift start', () => {
80
+ for (const patch of [
81
+ { start: '22:00', end: '06:00', breakStart: '02:00', breakEnd: '03:00' },
82
+ { start: '22:00', end: '06:00', breakStart: '23:30', breakEnd: '00:30' },
83
+ { start: '22:00', end: '06:00', breakStart: '22:00', breakEnd: '23:00' },
84
+ { start: '22:00', end: '06:00', breakStart: '05:00', breakEnd: '06:00' },
85
+ { start: '22:00', end: '06:00', breakStart: '12:00', breakEnd: '12:00' },
86
+ { breakStart: '00:00', breakEnd: '00:00' },
87
+ ])
88
+ assert.doesNotThrow(() => valid(patch));
89
+ for (const patch of [
90
+ { start: '09:00', end: '09:00' },
91
+ { start: '22:00', end: '06:00' },
92
+ { start: '22:00', end: '06:00', breakStart: '02:00', breakEnd: '01:00' },
93
+ { start: '22:00', end: '06:00', breakStart: '22:00', breakEnd: '06:00' },
94
+ { breakStart: '18:00', breakEnd: '19:00' },
95
+ { breakStart: '12:00', breakEnd: '11:00' },
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));
107
+ });
108
+
109
+ test('invalid patches and actions do not create files; queue recovers', async (t) => {
110
+ const { dir, operate } = await fixture(t);
111
+ for (const [action, patch] of [
112
+ ['wat'],
113
+ ['get', {}],
114
+ ['set'],
115
+ ['set', []],
116
+ ['set', { unknown: 1 }],
117
+ ['set', { payMode: 'weekly' }],
118
+ ]) {
119
+ assert.equal((await operate(action, patch)).ok, false);
120
+ }
121
+ assert.deepEqual(await readdir(dir), []);
122
+ assert.equal((await operate('set', {})).configured, true);
123
+ });
124
+
125
+ test('concurrent partial sets serialize and preserve preceding updates', async (t) => {
126
+ const { file, operate, dir } = await fixture(t);
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));
134
+ pending.push(operate('set', { unknown: true }));
135
+ for (let i = 0; i < 30; i++) pending.push(operate('set', { dailySalary: 1000 + i }));
136
+ const results = await Promise.all(pending);
137
+ assert.equal(results[4].ok, false);
138
+ assert.ok(results.filter((_, i) => i !== 4).every((result) => result.ok));
139
+ const expected = { ...DEFAULT_SETTINGS, ...Object.assign({}, ...patches), dailySalary: 1029 };
140
+ assert.deepEqual(JSON.parse(await readFile(file, 'utf8')), expected);
141
+ assert.deepEqual((await operate('get')).settings, expected);
142
+ assert.deepEqual(await readdir(dir), ['data.json']);
143
+ });
144
+
145
+ async function adapters(t) {
146
+ const fixtureData = await fixture(t);
147
+ // Import an isolated copy: apply() keeps its real default data URL beside the module,
148
+ // and neither adapter can accidentally reach the user's actual data.json.
149
+ const moduleFile = join(fixtureData.dir, 'host.mjs');
150
+ await writeFile(moduleFile, await readFile(source, 'utf8'));
151
+ const module = await import(pathToFileURL(moduleFile).href);
152
+ let command;
153
+ let tool;
154
+ module.apply({
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
+ },
168
+ });
169
+ const runCommand = async (rawInput) => {
170
+ const response = await command.handler({ rawInput });
171
+ const result = JSON.parse(response.text);
172
+ assert.equal(response.kind, result.ok ? 'success' : 'error');
173
+ return result;
174
+ };
175
+ return { ...fixtureData, tool, command, runCommand };
176
+ }
177
+
178
+ test('command and tool share results, validation, storage and a write queue', async (t) => {
179
+ const { tool, command, runCommand, file } = await adapters(t);
180
+ assert.equal(command.recordInput, false);
181
+ assert.deepEqual(await runCommand('get'), await tool.execute({ action: 'get' }));
182
+ const written = await runCommand('set {"payMode":"daily","dailySalary":600}');
183
+ assert.deepEqual(await tool.execute({ action: 'get' }), written);
184
+ const second = await tool.execute({ action: 'set', settings: { monthlySalary: 25000 } });
185
+ assert.deepEqual(await runCommand('get'), second);
186
+ assert.deepEqual(
187
+ await runCommand('set {"unknown":1}'),
188
+ await tool.execute({ action: 'set', settings: { unknown: 1 } }),
189
+ );
190
+ await Promise.all([
191
+ runCommand('set {"dailySalary":750}'),
192
+ tool.execute({ action: 'set', settings: { timezone: 'UTC' } }),
193
+ ]);
194
+ const stored = JSON.parse(await readFile(file, 'utf8'));
195
+ assert.equal(stored.dailySalary, 750);
196
+ assert.equal(stored.timezone, 'UTC');
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);
201
+ });
202
+
203
+ test('tool schemas expose new fields, configured flag, and typed literal branches', async (t) => {
204
+ const { tool } = await adapters(t);
205
+ const input = tool.parameters.properties.settings;
206
+ assert.equal(input.additionalProperties, false);
207
+ assert.deepEqual(input.properties.payMode.enum, ['monthly', 'daily']);
208
+ assert.equal(input.properties.dailySalary.type, 'number');
209
+ const [success, failure] = tool.output.schema.oneOf;
210
+ assert.deepEqual(success.properties.ok, { type: 'boolean', const: true });
211
+ assert.deepEqual(failure.properties.ok, { type: 'boolean', const: false });
212
+ assert.ok(success.required.includes('configured'));
213
+ assert.equal(success.properties.configured.type, 'boolean');
214
+ assert.deepEqual(success.properties.settings.required, Object.keys(DEFAULT_SETTINGS));
215
+ assert.equal(success.additionalProperties, false);
216
+ assert.equal(failure.additionalProperties, false);
217
+ const result = await tool.execute({ action: 'get' });
218
+ assert.deepEqual(tool.output.render({}, result), [
219
+ { type: 'text', text: JSON.stringify(result) },
220
+ ]);
221
+ });
@@ -0,0 +1,132 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { readFile } from 'node:fs/promises';
4
+ import {
5
+ formatDuration,
6
+ clampPosition,
7
+ normalizeView,
8
+ clampOpacity,
9
+ decodeCommand,
10
+ changedSettings,
11
+ } from '../ui-utils.js';
12
+ test('duration rounds countdown upward and supports >24 hours', () => {
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), '--:--:--');
19
+ });
20
+ test('position stays visible when viewport shrinks', () => {
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
+ });
29
+ });
30
+ test('view defaults: compact, salary hidden, 82% glass', () => {
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
+ );
41
+ assert.equal(normalizeView({ mini: 'yes' }).mini, true);
42
+ assert.equal(normalizeView({ masked: 'false' }).masked, true);
43
+ });
44
+ test('malformed positions and opacity are repaired, not trusted', () => {
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
+ });
51
+ assert.equal(normalizeView({ opacity: 0 }).opacity, 0.3);
52
+ assert.equal(normalizeView({ opacity: 9 }).opacity, 1);
53
+ assert.equal(normalizeView({ opacity: 'x' }).opacity, 0.82);
54
+ assert.equal(clampOpacity(0.55), 0.55);
55
+ });
56
+ test('salary stays hidden until explicitly revealed, including on first paint', () => {
57
+ assert.equal(normalizeView(null).masked, true);
58
+ assert.equal(normalizeView({}).masked, true);
59
+ assert.equal(normalizeView({ masked: false }).masked, false);
60
+ });
61
+ test('RPC decodes every failure layer and real command envelopes', () => {
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
+ );
75
+ assert.throws(() => decodeCommand({ ok: true }), /Command unavailable/);
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
+ );
84
+ });
85
+ test('partial saves preserve unrelated edits from another caller', () => {
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
+ );
93
+ });
94
+ test('Client uses the exact tested engine and a single surface', async () => {
95
+ const source = await readFile(new URL('../client.source.js', import.meta.url), 'utf8');
96
+ const built = await readFile(new URL('../client.js', import.meta.url), 'utf8');
97
+ const engine = (await readFile(new URL('../clock.js', import.meta.url), 'utf8')).replace(
98
+ /^export /gm,
99
+ '',
100
+ );
101
+ assert.ok(built.includes(engine));
102
+ assert.match(source, /prefers-reduced-motion:reduce/);
103
+ assert.match(source, /prefers-reduced-transparency:reduce/);
104
+ assert.match(source, /visibilitychange/);
105
+ assert.match(source, /removeEventListener\('storage'/);
106
+ assert.match(source, /ro\?\.disconnect/);
107
+ assert.match(source, /generation\.current/);
108
+ assert.doesNotMatch(source, /document\.body|window\.open|fetch\(/);
109
+ assert.match(source, /money\(safeState\.earned\)/);
110
+ assert.match(source, /owc-glass/);
111
+ assert.match(source, /--owc-alpha/);
112
+ assert.match(source, /type: 'range'/);
113
+ assert.match(source, /dragState/);
114
+ assert.ok(source.includes('onPointerMove: dragMove'));
115
+ });
116
+ test('compact strip stacks time above amount', async () => {
117
+ const source = await readFile(new URL('../client.source.js', import.meta.url), 'utf8');
118
+ const css = source.slice(source.indexOf('.owc-pill{'), source.indexOf('.owc-pill{') + 200);
119
+ assert.match(css, /flex-direction:column/);
120
+ assert.match(source, /className: 'owc-pill-top'/);
121
+ assert.match(source, /className: 'owc-pill-bottom'/);
122
+ assert.ok(
123
+ source.indexOf("className: 'owc-pill-time'") < source.indexOf("className: 'owc-pill-money'"),
124
+ );
125
+ });
126
+ test('compact strip keeps both drag surface and expand action; panel keeps collapse', async () => {
127
+ const source = await readFile(new URL('../client.source.js', import.meta.url), 'utf8');
128
+ assert.match(source, /onPointerDown: dragDown/);
129
+ assert.match(source, /mini: false/);
130
+ assert.match(source, /mini: true/);
131
+ assert.match(source, /hasPointerCapture/);
132
+ });
@@ -0,0 +1,28 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+
3
+ const root = new URL('../', import.meta.url);
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
+ }
24
+ console.log('Client artifact matches tested source');
25
+ } else {
26
+ await writeFile(output, result);
27
+ console.log('Built client.js from one calculation source');
28
+ }