dsh-offwork 1.0.1 → 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.
@@ -0,0 +1,211 @@
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
+ reconcileHydration,
9
+ hydrationView,
10
+ transitionHydration,
11
+ HYDRATION_LEASE_MS,
12
+ } from '../hydration.js';
13
+
14
+ const settings = {
15
+ payMode: 'monthly',
16
+ monthlySalary: 12000,
17
+ dailySalary: 600,
18
+ workdaysPerMonth: 20,
19
+ start: '09:00',
20
+ end: '18:00',
21
+ breakStart: '12:00',
22
+ breakEnd: '13:00',
23
+ weekdays: [1, 2, 3, 4, 5],
24
+ timezone: 'Asia/Shanghai',
25
+ };
26
+ const time = (text) => Date.parse(text.includes('T') ? text : `2026-11-04T${text}:00+08:00`);
27
+ const context = (text, patch = {}) => {
28
+ const config = { ...settings, ...patch };
29
+ return hydrationContext(config, calculate(config, time(text)));
30
+ };
31
+ const act = (state, at, action, patch = {}, owner = 'tab-a') =>
32
+ transitionHydration(state, context(at, patch), action, time(at), owner);
33
+ const enable = (at = '09:00', interval = 60, patch = {}) =>
34
+ act(defaultHydration(), at, { type: 'preferences', enabled: true, interval }, patch);
35
+ const view = (state, at, patch = {}) =>
36
+ hydrationView(reconcileHydration(state, context(at, patch)), context(at, patch), time(at));
37
+ const claim = (state, at, patch = {}, owner = 'tab-a') =>
38
+ act(state, at, { type: 'claim' }, patch, owner);
39
+
40
+ test('off by default; enabling mid-shift starts a full interval, not catch-up', () => {
41
+ assert.equal(view(defaultHydration(), '11:45').phase, 'off');
42
+ const state = enable('10:15');
43
+ assert.equal(view(state, '10:15').remaining, 3600000);
44
+ assert.equal(view(state, '11:14').phase, 'countdown');
45
+ assert.equal(view(state, '11:15').phase, 'due');
46
+ });
47
+
48
+ for (const interval of [5, 15, 17, 30, 45, 60, 90, 120, 240]) {
49
+ test(`saving ${interval} minutes applies that interval instead of the 60-minute default`, () => {
50
+ const initial = enable('09:00');
51
+ const saved = act(initial, '09:10', { type: 'preferences', enabled: true, interval });
52
+ assert.equal(saved.interval, interval);
53
+ const elapsed = context('09:10').elapsed;
54
+ // Saving an unchanged 60-minute interval keeps the existing cycle.
55
+ const anchor = interval === 60 ? 0 : elapsed;
56
+ assert.equal(saved.shift.due, anchor + interval * 60000);
57
+ const reopened = validateHydration(JSON.parse(JSON.stringify(saved)));
58
+ assert.equal(reopened.interval, interval);
59
+ const deadline =
60
+ time('09:00') + saved.shift.due + (saved.shift.due >= 3 * 3600000 ? 3600000 : 0);
61
+ const before = hydrationContext(settings, calculate(settings, deadline - 1));
62
+ const atDue = hydrationContext(settings, calculate(settings, deadline));
63
+ assert.equal(hydrationView(reopened, before).phase, 'countdown');
64
+ assert.equal(hydrationView(reopened, atDue).phase, 'due');
65
+ });
66
+ }
67
+
68
+ test('break freezes working-time countdown; snooze crosses break', () => {
69
+ const state = enable('11:10');
70
+ assert.equal(view(state, '12:00').phase, 'break');
71
+ assert.equal(view(state, '12:50').remaining, 600000);
72
+ assert.equal(view(state, '13:10').phase, 'due');
73
+ let snoozed = claim(enable('10:58'), '11:58');
74
+ snoozed = act(snoozed, '11:58', { type: 'snooze', eventId: view(snoozed, '11:58').eventId });
75
+ assert.equal(view(snoozed, '12:30').remaining, 480000);
76
+ assert.equal(view(snoozed, '13:07').phase, 'countdown');
77
+ assert.equal(view(snoozed, '13:08').phase, 'due');
78
+ assert.equal(snoozed.shift.count, 0);
79
+ });
80
+
81
+ test('completion is idempotent and schedules from actual click, not old due time', () => {
82
+ let state = claim(enable(), '10:03');
83
+ const eventId = view(state, '10:03').eventId;
84
+ state = act(state, '10:03', { type: 'drink', eventId });
85
+ state = act(state, '10:03', { type: 'drink', eventId });
86
+ assert.equal(state.shift.count, 1);
87
+ assert.equal(view(state, '11:02').phase, 'countdown');
88
+ assert.equal(view(state, '11:03').phase, 'due');
89
+ });
90
+
91
+ test('hours of missed ticks yield a single stable event; shift end discards it', () => {
92
+ const state = enable();
93
+ assert.equal(view(state, '10:00').eventId, view(state, '17:30').eventId);
94
+ assert.equal(view(state, '17:30').phase, 'due');
95
+ assert.equal(view(state, '18:00').phase, 'waiting');
96
+ assert.equal(view(state, '2026-11-07T11:00:00+08:00').phase, 'waiting');
97
+ });
98
+
99
+ test('pause persists through overnight midnight and resets at a new shift', () => {
100
+ const night = { start: '22:00', end: '06:00', breakStart: '02:00', breakEnd: '03:00' };
101
+ let state = enable('2026-11-06T22:00:00+08:00', 60, night);
102
+ state = act(state, '2026-11-06T23:00:00+08:00', { type: 'pause' }, night);
103
+ assert.equal(view(state, '2026-11-07T01:00:00+08:00', night).phase, 'paused');
104
+ assert.equal(view(state, '2026-11-07T22:00:00+08:00', night).phase, 'waiting');
105
+ const next = reconcileHydration(state, context('2026-11-09T22:00:00+08:00', night));
106
+ assert.equal(hydrationView(next, context('2026-11-09T22:00:00+08:00', night)).phase, 'countdown');
107
+ assert.equal(next.shift.paused, false);
108
+ });
109
+
110
+ test('resume and interval changes restart; count reset does not alter timer', () => {
111
+ let state = claim(enable(), '10:00');
112
+ state = act(state, '10:00', { type: 'drink', eventId: view(state, '10:00').eventId });
113
+ const due = state.shift.due;
114
+ state = act(state, '10:10', { type: 'clear-count' });
115
+ assert.equal(state.shift.count, 0);
116
+ assert.equal(state.shift.due, due);
117
+ state = act(state, '10:10', { type: 'pause' });
118
+ state = act(state, '10:40', { type: 'resume' });
119
+ assert.equal(view(state, '10:40').remaining, 3600000);
120
+ state = act(state, '10:50', { type: 'preferences', enabled: true, interval: 5 });
121
+ assert.equal(view(state, '10:50').remaining, 300000);
122
+ assert.equal(view(state, '10:55').phase, 'due');
123
+ });
124
+
125
+ test('schedule changes retain same-shift count and pause; pay changes do not reschedule', () => {
126
+ let state = claim(enable(), '10:00');
127
+ state = act(state, '10:00', { type: 'drink', eventId: view(state, '10:00').eventId });
128
+ const oldDue = state.shift.due;
129
+ assert.equal(
130
+ reconcileHydration(state, context('10:30', { monthlySalary: 15000 })).shift.due,
131
+ oldDue,
132
+ );
133
+ state = act(state, '10:30', { type: 'pause' });
134
+ const shifted = reconcileHydration(state, context('10:45', { end: '19:00' }));
135
+ assert.equal(shifted.shift.count, 1);
136
+ assert.equal(shifted.shift.paused, true);
137
+ assert.equal(shifted.shift.due - context('10:45', { end: '19:00' }).elapsed, 3600000);
138
+ });
139
+
140
+ test('only the current lease owner can process a live event; expired lease is transferred', () => {
141
+ let state = claim(enable(), '10:00');
142
+ const eventId = view(state, '10:00').eventId;
143
+ state = claim(state, '10:00', {}, 'tab-b');
144
+ assert.equal(state.shift.claim.owner, 'tab-a');
145
+ assert.equal(act(state, '10:00', { type: 'drink', eventId }, {}, 'tab-b').shift.count, 0);
146
+ const at = time('10:00') + HYDRATION_LEASE_MS + 1;
147
+ const ctx = hydrationContext(settings, calculate(settings, at));
148
+ state = transitionHydration(state, ctx, { type: 'claim' }, at, 'tab-b');
149
+ assert.equal(state.shift.claim.owner, 'tab-b');
150
+ assert.equal(
151
+ transitionHydration(state, ctx, { type: 'drink', eventId }, at, 'tab-a').shift.count,
152
+ 0,
153
+ );
154
+ assert.equal(
155
+ transitionHydration(state, ctx, { type: 'drink', eventId }, at, 'tab-b').shift.count,
156
+ 1,
157
+ );
158
+ });
159
+
160
+ test('release, disabled settings and break boundaries remove a displayed lease', () => {
161
+ let state = claim(enable(), '10:00');
162
+ assert.equal(act(state, '10:00', { type: 'release' }).shift.claim, null);
163
+ assert.equal(
164
+ act(state, '10:00', { type: 'preferences', enabled: false, interval: 60 }).shift.claim,
165
+ null,
166
+ );
167
+ state = claim(enable('11:00'), '11:59');
168
+ assert.equal(reconcileHydration(state, context('12:00')).shift.claim, null);
169
+ assert.deepEqual(act(state, '12:00', { type: 'reset' }), defaultHydration());
170
+ });
171
+
172
+ test('at exact shift end there is no reminder; first opening tomorrow starts fresh', () => {
173
+ const state = enable('17:00');
174
+ assert.equal(view(state, '17:00').phase, 'finished');
175
+ assert.notEqual(view(state, '18:00').phase, 'due');
176
+ assert.equal(view(state, '2026-11-05T10:25:00+08:00').remaining, 3600000);
177
+ });
178
+
179
+ test('DST forward and backward transitions count actual paid elapsed time', () => {
180
+ const patch = {
181
+ timezone: 'America/New_York',
182
+ start: '22:00',
183
+ end: '06:00',
184
+ breakStart: '00:00',
185
+ breakEnd: '00:00',
186
+ weekdays: [6],
187
+ };
188
+ const spring = enable('2026-03-08T06:30:00Z', 60, patch);
189
+ assert.equal(view(spring, '2026-03-08T07:00:00Z', patch).remaining, 1800000);
190
+ assert.equal(view(spring, '2026-03-08T07:30:00Z', patch).phase, 'due');
191
+ const fall = enable('2026-11-01T05:30:00Z', 60, patch);
192
+ assert.equal(view(fall, '2026-11-01T06:30:00Z', patch).phase, 'due');
193
+ });
194
+
195
+ test('malformed data, unknown schemas and invalid preferences fail closed', () => {
196
+ for (const data of [
197
+ null,
198
+ {},
199
+ [],
200
+ { ...defaultHydration(), schema: 99 },
201
+ ,
202
+ { ...defaultHydration(), interval: 0 },
203
+ { ...defaultHydration(), shift: {} },
204
+ ]) {
205
+ assert.throws(() => validateHydration(data));
206
+ }
207
+ for (const interval of [0, 4, 241, 5.5, NaN]) assert.throws(() => enable('09:00', interval));
208
+ const state = enable();
209
+ assert.deepEqual(validateHydration(state), state);
210
+ assert.equal(hydrationView(state, null).phase, 'waiting');
211
+ });
@@ -0,0 +1,56 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { petLayout, petModel, petArtwork } from '../pet-view.js';
4
+ import { normalizeView } from '../ui-utils.js';
5
+
6
+ test('existing preferences gain the long dress without losing privacy, position or motion choices', () => {
7
+ const old = {
8
+ masked: true,
9
+ companion: false,
10
+ companionMotion: false,
11
+ petSize: 148,
12
+ petPosition: { x: 300, y: 400 },
13
+ opacity: 0.7,
14
+ };
15
+ const next = normalizeView(old);
16
+ assert.equal(next.petModel, 'whale-dress');
17
+ for (const k of Object.keys(old)) assert.deepEqual(next[k], old[k]);
18
+ for (const id of ['whale-dress', 'whale-ornate', 'xiaoxie-original'])
19
+ assert.equal(normalizeView({ ...next, petModel: id }).petModel, id);
20
+ for (const id of ['unknown', '__proto__', 'toString']) {
21
+ assert.equal(normalizeView({ petModel: id }).petModel, 'whale-dress');
22
+ assert.equal(petModel(id).id, 'whale-dress');
23
+ }
24
+ });
25
+ test('portrait layout fits the complete figure and badge in narrow, short and offset viewports', () => {
26
+ for (const id of ['whale-dress', 'whale-ornate'])
27
+ for (const size of [80, 132, 180])
28
+ for (const viewport of [
29
+ { width: 1440, height: 1000 },
30
+ { width: 320, height: 568 },
31
+ { width: 640, height: 320 },
32
+ { width: 240, height: 180, left: 50, top: 200 },
33
+ ]) {
34
+ const p = petLayout({ x: 9999, y: 9999 }, viewport, size, 420, id),
35
+ m = petModel(id);
36
+ assert.ok(p.x >= (viewport.left ?? 0) && p.y >= (viewport.top ?? 0));
37
+ assert.ok(p.x + p.petWidth <= (viewport.left ?? 0) + viewport.width);
38
+ assert.ok(p.y + p.petHeight <= (viewport.top ?? 0) + viewport.height);
39
+ assert.ok(p.artworkSize * m.widthScale <= p.petWidth);
40
+ assert.ok(p.artworkSize * m.heightScale + 26 <= p.petHeight);
41
+ }
42
+ });
43
+ test('portrait uses an inert image with proportional dimensions; missing resource retains the SVG fallback', () => {
44
+ const h = (type, props, ...children) => ({ type, props, children }),
45
+ presentation = { pose: 'drink', animate: true };
46
+ const im = petArtwork(h, presentation, 132, 'event-1', 'whale-dress', {
47
+ 'whale-dress': 'data:image/webp;base64,test',
48
+ });
49
+ assert.equal(im.type, 'img');
50
+ assert.equal(im.props.draggable, false);
51
+ assert.equal(im.props['aria-hidden'], true);
52
+ assert.equal(im.props.width / im.props.height, 640 / 1024);
53
+ assert.equal(im.props['data-animate'], true);
54
+ assert.equal(petArtwork(h, presentation, 132, '', 'whale-dress', {}).type, 'svg');
55
+ assert.equal(petArtwork(h, presentation, 132, '', 'xiaoxie-original', {}).type, 'svg');
56
+ });
@@ -0,0 +1,88 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { initialPetUI, petUIReducer, appearanceValues } from '../pet-interactions.js';
4
+ const go = (s, screen, extra = {}) => petUIReducer(s, { type: 'navigate', screen, ...extra });
5
+
6
+ test('one screen is active and editor navigation retains a predictable parent', () => {
7
+ let s = go(initialPetUI, 'home');
8
+ s = go(s, 'income');
9
+ s = go(s, 'water');
10
+ assert.equal(s.screen, 'water');
11
+ assert.equal(s.back, 'income');
12
+ s = go(s, 'appearance');
13
+ assert.equal(s.back, 'income');
14
+ s = go(s, s.back);
15
+ assert.equal(s.screen, 'income');
16
+ s = go(s, null);
17
+ assert.equal(s.screen, null);
18
+ assert.equal(go(initialPetUI, 'appearance').back, null);
19
+ });
20
+ test('dirty navigation preserves the editor until save or explicit discard', () => {
21
+ const editing = go(go(initialPetUI, 'home'), 'water');
22
+ const blocked = go(editing, 'appearance', { dirty: true });
23
+ assert.equal(blocked.screen, 'water');
24
+ assert.deepEqual(blocked.pending, { screen: 'appearance' });
25
+ assert.equal(petUIReducer(blocked, { type: 'stay' }).screen, 'water');
26
+ const discarded = go(blocked, blocked.pending.screen, { discard: true });
27
+ assert.equal(discarded.screen, 'appearance');
28
+ assert.equal(discarded.pending, null);
29
+ const close = go(editing, null, { dirty: true });
30
+ assert.deepEqual(close.pending, { screen: null });
31
+ assert.equal(close.screen, 'water');
32
+ assert.equal(go(close, null, { discard: true }).screen, null);
33
+ });
34
+ test('context menu does not navigate or destroy the pending editor state', () => {
35
+ const original = go(go(initialPetUI, 'home'), 'schedule');
36
+ const menu = petUIReducer(original, { type: 'menu', position: { x: 20, y: 30 } });
37
+ assert.equal(menu.screen, 'schedule');
38
+ assert.equal(menu.back, 'home');
39
+ assert.deepEqual(petUIReducer(menu, { type: 'dismiss-menu' }), original);
40
+ assert.equal(go(menu, 'appearance', { dirty: true }).menu, null);
41
+ assert.deepEqual(original, go(go(initialPetUI, 'home'), 'schedule'));
42
+ });
43
+ test('reopening the current page preserves drafts and unsupported navigation is ignored', () => {
44
+ const s = go(go(initialPetUI, 'home'), 'water');
45
+ assert.deepEqual(go(s, 'water', { dirty: true }), s);
46
+ assert.equal(go(s, 'unknown'), s);
47
+ });
48
+ test('menu cards are independent roots while reminder settings return only to water', () => {
49
+ const water = go(initialPetUI, 'home', { root: true });
50
+ const settings = go(water, 'water');
51
+ assert.equal(settings.back, 'home');
52
+ const clock = go(settings, 'income', { root: true, discard: true });
53
+ assert.equal(clock.back, null);
54
+ const appearance = go(clock, 'appearance', { root: true });
55
+ assert.equal(appearance.back, null);
56
+ assert.equal(go(appearance, appearance.back).screen, null);
57
+ assert.equal(go(initialPetUI, 'earnings', { root: true }).screen, 'earnings');
58
+ });
59
+ test('dirty navigation retains the root destination for explicit save or discard', () => {
60
+ const settings = go(go(initialPetUI, 'home'), 'water');
61
+ const pending = go(settings, 'appearance', { root: true, dirty: true });
62
+ assert.deepEqual(pending.pending, { screen: 'appearance', root: true });
63
+ const next = go(pending, pending.pending.screen, { root: pending.pending.root, discard: true });
64
+ assert.equal(next.back, null);
65
+ assert.equal(next.screen, 'appearance');
66
+ });
67
+
68
+ test('appearance snapshots omit position and salary preferences', () => {
69
+ assert.deepEqual(
70
+ appearanceValues({
71
+ companion: true,
72
+ companionMotion: false,
73
+ petSize: 132,
74
+ petModel: 'whale-ornate',
75
+ opacity: 0.8,
76
+ position: { x: 1, y: 2 },
77
+ petPosition: { x: 3, y: 4 },
78
+ masked: false,
79
+ }),
80
+ {
81
+ companion: true,
82
+ companionMotion: false,
83
+ petSize: 132,
84
+ petModel: 'whale-ornate',
85
+ opacity: 0.8,
86
+ },
87
+ );
88
+ });
@@ -0,0 +1,128 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { petViewport, petSurfaceWidth, placePetSurface } from '../pet-surfaces.js';
4
+ import { petLayout } from '../pet-view.js';
5
+ import { clampPosition } from '../ui-utils.js';
6
+ const overlap = (a, b) =>
7
+ Math.max(0, Math.min(a.left + a.width, b.x + b.width) - Math.max(a.left, b.x)) *
8
+ Math.max(0, Math.min(a.top + a.height, b.y + b.height) - Math.max(a.top, b.y));
9
+
10
+ test('visual viewport bounds include keyboard and zoom offsets, with a desktop fallback', () => {
11
+ assert.deepEqual(petViewport({ innerWidth: 1440, innerHeight: 900 }), {
12
+ width: 1440,
13
+ height: 900,
14
+ left: 0,
15
+ top: 0,
16
+ });
17
+ assert.deepEqual(
18
+ petViewport({
19
+ innerWidth: 800,
20
+ innerHeight: 900,
21
+ visualViewport: { width: 390, height: 310, offsetLeft: 20, offsetTop: 240 },
22
+ }),
23
+ { width: 390, height: 310, left: 20, top: 240 },
24
+ );
25
+ });
26
+ test('card widths match content needs and shrink to the available viewport', () => {
27
+ const wide = { width: 1440, height: 900 };
28
+ assert.ok(petSurfaceWidth('schedule', wide) > petSurfaceWidth('income', wide));
29
+ assert.ok(petSurfaceWidth('menu', wide) < petSurfaceWidth('home', wide));
30
+ for (const name of ['home', 'income', 'earnings', 'water', 'appearance', 'schedule'])
31
+ assert.equal(petSurfaceWidth(name, { width: 320, height: 568 }), 296);
32
+ });
33
+ test('measured surfaces stay inside every viewport and preserve natural height when there is room', () => {
34
+ for (const viewport of [
35
+ { width: 1440, height: 900 },
36
+ { width: 768, height: 1024 },
37
+ { width: 320, height: 568 },
38
+ { width: 640, height: 320 },
39
+ { width: 240, height: 140 },
40
+ { width: 390, height: 280, left: 80, top: 220 },
41
+ ])
42
+ for (const position of [null, { x: 0, y: 0 }, { x: 9999, y: 9999 }])
43
+ for (const size of [80, 180])
44
+ for (const height of [42, 230, 480, 1500])
45
+ for (const kind of ['card', 'menu']) {
46
+ const p = petLayout(position, viewport, size);
47
+ const anchor = { x: p.x, y: p.y, width: p.petWidth, height: p.petHeight };
48
+ const r = placePetSurface(anchor, viewport, {
49
+ width: petSurfaceWidth(kind === 'menu' ? 'menu' : 'schedule', viewport),
50
+ height,
51
+ kind,
52
+ point: { x: p.x, y: p.y },
53
+ });
54
+ assert.ok(r.left >= (viewport.left ?? 0) && r.top >= (viewport.top ?? 0));
55
+ assert.ok(r.left + r.width <= (viewport.left ?? 0) + viewport.width + 0.01);
56
+ assert.ok(r.top + r.height <= (viewport.top ?? 0) + viewport.height + 0.01);
57
+ assert.ok(r.height <= height);
58
+ }
59
+ const r = placePetSurface(
60
+ { x: 1100, y: 500, width: 160, height: 176 },
61
+ { width: 1440, height: 900 },
62
+ { width: 320, height: 230 },
63
+ );
64
+ assert.equal(r.height, 230);
65
+ });
66
+ test('cards and menus avoid the character whenever a clear side is available', () => {
67
+ const viewport = { width: 1440, height: 900 };
68
+ for (const anchor of [
69
+ { x: 1100, y: 650, width: 160, height: 176 },
70
+ { x: 12, y: 12, width: 160, height: 176 },
71
+ ])
72
+ for (const kind of ['card', 'menu']) {
73
+ const r = placePetSurface(anchor, viewport, {
74
+ width: 360,
75
+ height: 500,
76
+ kind,
77
+ point: { x: anchor.x + 80, y: anchor.y + 80 },
78
+ });
79
+ assert.equal(overlap(r, anchor), 0);
80
+ }
81
+ });
82
+ test('content changes keep the header in place until bounds require movement', () => {
83
+ const anchor = { x: 1100, y: 600, width: 160, height: 176 },
84
+ viewport = { width: 1440, height: 1000 };
85
+ const first = placePetSurface(anchor, viewport, { width: 336, height: 200 });
86
+ const larger = placePetSurface(anchor, viewport, { width: 336, height: 240, previous: first });
87
+ assert.equal(larger.top, first.top);
88
+ assert.equal(larger.left, first.left);
89
+ assert.equal(larger.height, 240);
90
+ const tall = placePetSurface(anchor, viewport, { width: 336, height: 1400, previous: larger });
91
+ assert.ok(tall.top + tall.height <= 988);
92
+ });
93
+ test('menus open near a clicked card rather than jumping back to the pet', () => {
94
+ const r = placePetSurface(
95
+ { x: 1100, y: 700, width: 148, height: 176 },
96
+ { width: 1440, height: 1000 },
97
+ { width: 224, height: 240, kind: 'menu', point: { x: 300, y: 100 } },
98
+ );
99
+ assert.equal(r.left, 308);
100
+ assert.equal(r.top, 108);
101
+ });
102
+
103
+ test('water notices avoid the open card as well as the pet', () => {
104
+ const viewport = { width: 320, height: 568 };
105
+ const anchor = { x: 140, y: 414, width: 148, height: 118 };
106
+ const card = { left: 12, top: 12, width: 296, height: 390 };
107
+ const notice = placePetSurface(anchor, viewport, {
108
+ width: 116,
109
+ height: 34,
110
+ kind: 'menu',
111
+ obstacles: [card],
112
+ });
113
+ assert.equal(overlap(notice, anchor), 0);
114
+ assert.equal(
115
+ overlap(notice, { x: card.left, y: card.top, width: card.width, height: card.height }),
116
+ 0,
117
+ );
118
+ });
119
+
120
+ test('pet drag clamping and surface placement use the same offset coordinate space', () => {
121
+ const viewport = { width: 390, height: 310, left: 80, top: 240 };
122
+ const position = clampPosition({ x: -50, y: 9999 }, viewport, { width: 148, height: 176 });
123
+ assert.equal(position.x, 92);
124
+ assert.equal(position.y, 362);
125
+ const pet = petLayout(position, viewport, 132);
126
+ assert.equal(pet.x, 92);
127
+ assert.equal(pet.y, 362);
128
+ });
@@ -0,0 +1,48 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { petLayout } from '../pet-view.js';
4
+ import { normalizeView } from '../ui-utils.js';
5
+
6
+ test('pet and popover stay on screen at edges, across sizes and narrow viewports', () => {
7
+ for (const viewport of [
8
+ { width: 1440, height: 900 },
9
+ { width: 390, height: 844 },
10
+ { width: 320, height: 568 },
11
+ { width: 280, height: 360 },
12
+ { width: 320, height: 160 },
13
+ { width: 800, height: 120 },
14
+ ]) {
15
+ for (const size of [80, 132, 180])
16
+ for (const position of [null, { x: -500, y: -500 }, { x: 9999, y: 9999 }, { x: 120, y: 80 }])
17
+ for (const height of [120, 420, 900]) {
18
+ const r = petLayout(position, viewport, size, height);
19
+ assert.ok(r.x >= 0 && r.x + r.petWidth <= viewport.width);
20
+ assert.ok(r.y >= 0 && r.y + r.petHeight <= viewport.height);
21
+ assert.ok(r.panel.left >= 0 && r.panel.left + r.panel.width <= viewport.width);
22
+ assert.ok(
23
+ r.panel.top >= 0 &&
24
+ r.panel.top + Math.min(height, r.panel.maxHeight) <= viewport.height,
25
+ );
26
+ }
27
+ }
28
+ });
29
+
30
+ test('popover flips to the available side and stacks on a phone', () => {
31
+ assert.equal(petLayout(null, { width: 1440, height: 900 }).panel.side, 'left');
32
+ assert.equal(petLayout({ x: 12, y: 200 }, { width: 1440, height: 900 }).panel.side, 'right');
33
+ assert.equal(petLayout(null, { width: 320, height: 568 }).panel.side, 'above');
34
+ assert.equal(petLayout({ x: 12, y: 12 }, { width: 320, height: 568 }).panel.side, 'below');
35
+ });
36
+
37
+ test('pet preferences retain the original clock position and bound invalid size input', () => {
38
+ const old = { position: { x: 40, y: 50 }, masked: true, companion: true, opacity: 0.6 };
39
+ const next = normalizeView(old);
40
+ assert.deepEqual(next.position, old.position);
41
+ assert.equal(next.petPosition, null);
42
+ assert.equal(next.petSize, 132);
43
+ const saved = normalizeView({ ...next, petPosition: { x: 200, y: 300 }, petSize: 148 });
44
+ assert.deepEqual(normalizeView(JSON.parse(JSON.stringify(saved))), saved);
45
+ assert.equal(normalizeView({ petSize: 9000 }).petSize, 180);
46
+ assert.equal(normalizeView({ petSize: NaN }).petSize, 132);
47
+ assert.equal(normalizeView({ petPosition: { x: 'bad', y: 4 } }).petPosition, null);
48
+ });
package/tests/ui.test.mjs CHANGED
@@ -27,24 +27,44 @@ test('position stays visible when viewport shrinks', () => {
27
27
  y: 12,
28
28
  });
29
29
  });
30
- test('view defaults: compact, salary hidden, 82% glass', () => {
30
+ test('view defaults: compact, salary hidden, 82% glass and companion enabled', () => {
31
31
  assert.deepEqual(normalizeView(null), {
32
+ petModel: 'whale-dress',
33
+ petSize: 132,
34
+ petPosition: null,
32
35
  mini: true,
33
36
  masked: true,
37
+ companion: true,
38
+ companionMotion: true,
34
39
  opacity: 0.82,
35
40
  position: null,
36
41
  });
37
42
  assert.deepEqual(
38
43
  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 } },
44
+ {
45
+ petSize: 132,
46
+ petModel: 'whale-dress',
47
+ petPosition: null,
48
+ mini: false,
49
+ masked: false,
50
+ companion: true,
51
+ companionMotion: true,
52
+ opacity: 0.5,
53
+ position: { x: 1, y: 2 },
54
+ },
40
55
  );
41
56
  assert.equal(normalizeView({ mini: 'yes' }).mini, true);
42
57
  assert.equal(normalizeView({ masked: 'false' }).masked, true);
43
58
  });
44
59
  test('malformed positions and opacity are repaired, not trusted', () => {
45
60
  assert.deepEqual(normalizeView({ mini: 'yes', masked: true, position: { x: 'oops', y: 2 } }), {
61
+ petModel: 'whale-dress',
62
+ petSize: 132,
63
+ petPosition: null,
46
64
  mini: true,
47
65
  masked: true,
66
+ companion: true,
67
+ companionMotion: true,
48
68
  opacity: 0.82,
49
69
  position: null,
50
70
  });
package/tools/build.mjs CHANGED
@@ -1,7 +1,17 @@
1
1
  import { readFile, writeFile } from 'node:fs/promises';
2
2
 
3
3
  const root = new URL('../', import.meta.url);
4
- const moduleNames = ['client.source.js', 'clock.js', 'ui-utils.js'];
4
+ const moduleNames = [
5
+ 'client.source.js',
6
+ 'clock.js',
7
+ 'ui-utils.js',
8
+ 'companion.js',
9
+ 'pet-view.js',
10
+ 'pet-interactions.js',
11
+ 'pet-surfaces.js',
12
+ 'hydration.js',
13
+ 'hydration-store.js',
14
+ ];
5
15
  const [entry, ...modules] = await Promise.all(
6
16
  moduleNames.map((name) => readFile(new URL(name, root), 'utf8')),
7
17
  );
@@ -10,11 +20,31 @@ if (entry.split(marker).length !== 2) {
10
20
  throw new Error('Expected exactly one pure-module marker in client.source.js');
11
21
  }
12
22
 
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);
23
+ // Embed the tested modules, removing only their internal module wiring.
24
+ // The browser and Node.js tests execute the same calculation and store code.
25
+ const pure = modules
26
+ .map((source) =>
27
+ source.replace(/^import .* from '\.\/hydration\.js';\n/gm, '').replace(/^export /gm, ''),
28
+ )
29
+ .join('\n');
30
+ const banner = '// Generated by tools/build.mjs; edit the source modules, not this file.\n';
31
+ const spriteMarker = '/* PET_SPRITES */';
32
+ if (entry.split(spriteMarker).length !== 2) throw new Error('Expected one sprite marker');
33
+ const sprites = Object.fromEntries(
34
+ await Promise.all(
35
+ ['whale-dress', 'whale-ornate'].map(async (id) => {
36
+ const bytes = await readFile(new URL(`assets/characters/${id}.webp`, root));
37
+ if (bytes.toString('ascii', 8, 12) !== 'WEBP' || bytes.length > 200000)
38
+ throw new Error(`Invalid sprite: ${id}`);
39
+ return [id, 'data:image/webp;base64,' + bytes.toString('base64')];
40
+ }),
41
+ ),
42
+ );
43
+ const result =
44
+ banner +
45
+ entry
46
+ .replace(marker, pure)
47
+ .replace(spriteMarker, `const PET_SPRITES = ${JSON.stringify(sprites)};`);
18
48
  const output = new URL('client.js', root);
19
49
 
20
50
  if (process.argv.includes('--check')) {
@@ -68,8 +68,17 @@ for (const path of [
68
68
  'package.json',
69
69
  'index.js',
70
70
  'client.js',
71
+ 'companion.js',
72
+ 'pet-view.js',
73
+ 'pet-interactions.js',
74
+ 'pet-surfaces.js',
75
+ 'hydration.js',
76
+ 'hydration-store.js',
71
77
  'cordis.patch.yml',
72
78
  'icon.svg',
79
+ 'assets/characters/whale-dress.webp',
80
+ 'assets/characters/whale-ornate.webp',
81
+ 'assets/characters/NOTICE.md',
73
82
  'locale/zh.json',
74
83
  'locale/en.json',
75
84
  'README.md',