dsh-offwork 1.0.1 → 1.1.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.
- package/CHANGELOG.md +19 -0
- package/README.md +38 -20
- package/assets/animations/NOTICE.md +15 -0
- package/assets/animations/alpha-probe.webm +0 -0
- package/assets/animations/ornate-drink.webm +0 -0
- package/assets/animations/ornate-greet.webm +0 -0
- package/assets/animations/ornate-idle.webm +0 -0
- package/assets/animations/whale-drink.webm +0 -0
- package/assets/animations/whale-greet.webm +0 -0
- package/assets/characters/NOTICE.md +11 -0
- package/assets/characters/whale-actions.webp +0 -0
- package/assets/characters/whale-dress.webp +0 -0
- package/assets/characters/whale-ornate-actions.webp +0 -0
- package/assets/characters/whale-ornate.webp +0 -0
- package/client.js +4033 -188
- package/client.source.js +2449 -214
- package/companion.js +185 -0
- package/hydration-store.js +101 -0
- package/hydration.js +303 -0
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +13 -2
- package/pet-interactions.js +138 -0
- package/pet-motion.js +110 -0
- package/pet-surfaces.js +194 -0
- package/pet-video.js +231 -0
- package/pet-view.js +323 -0
- package/tests/companion.test.mjs +86 -0
- 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 +62 -0
- package/tests/pet-interactions.test.mjs +88 -0
- package/tests/pet-motion.test.mjs +139 -0
- package/tests/pet-surfaces.test.mjs +128 -0
- package/tests/pet-video.test.mjs +337 -0
- package/tests/pet-view.test.mjs +48 -0
- package/tests/ui.test.mjs +22 -2
- package/tools/build.mjs +62 -6
- package/tools/check-package.mjs +20 -0
- package/ui-utils.js +22 -4
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createPetMotion } from '../pet-motion.js';
|
|
4
|
+
import { petArtwork } from '../pet-view.js';
|
|
5
|
+
|
|
6
|
+
function fixture(options) {
|
|
7
|
+
const document = Object.assign(new EventTarget(), { hidden: false });
|
|
8
|
+
const media = Object.assign(new EventTarget(), { matches: false });
|
|
9
|
+
const runs = [];
|
|
10
|
+
const element = {
|
|
11
|
+
dataset: {},
|
|
12
|
+
style: {},
|
|
13
|
+
animate(frames, timing) {
|
|
14
|
+
let finish, reject;
|
|
15
|
+
const finished = new Promise((resolve, fail) => {
|
|
16
|
+
finish = resolve;
|
|
17
|
+
reject = fail;
|
|
18
|
+
});
|
|
19
|
+
const run = {
|
|
20
|
+
frames,
|
|
21
|
+
timing,
|
|
22
|
+
finished,
|
|
23
|
+
finish,
|
|
24
|
+
cancelled: false,
|
|
25
|
+
cancel() {
|
|
26
|
+
this.cancelled = true;
|
|
27
|
+
reject(new Error('cancelled'));
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
runs.push(run);
|
|
31
|
+
return run;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
const controller = createPetMotion(element, { document, matchMedia: () => media }, options);
|
|
35
|
+
return { document, media, runs, element, controller };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test('explicit reactions interrupt safely and old completion cannot clear the next action', async () => {
|
|
39
|
+
const { controller: c, element: e, runs } = fixture();
|
|
40
|
+
assert.equal(e.dataset.motion, 'idle');
|
|
41
|
+
assert.equal(c.play('look'), true);
|
|
42
|
+
assert.equal(c.play('greet'), true);
|
|
43
|
+
await Promise.resolve();
|
|
44
|
+
assert.equal(runs[0].cancelled, true);
|
|
45
|
+
assert.equal(e.dataset.action, 'greet');
|
|
46
|
+
c.play('celebrate');
|
|
47
|
+
assert.equal(c.play('look'), false);
|
|
48
|
+
assert.equal(c.play('greet'), false);
|
|
49
|
+
await Promise.resolve();
|
|
50
|
+
assert.equal(e.dataset.action, 'celebrate');
|
|
51
|
+
runs.at(-1).finish();
|
|
52
|
+
await Promise.resolve();
|
|
53
|
+
assert.equal(e.dataset.action, undefined);
|
|
54
|
+
assert.equal(e.dataset.motion, 'idle');
|
|
55
|
+
c.destroy();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('motion preference, visibility and live reduced-motion changes stop actions without replay', async () => {
|
|
59
|
+
const { controller: c, element: e, document: d, media, runs } = fixture();
|
|
60
|
+
c.play('celebrate');
|
|
61
|
+
d.hidden = true;
|
|
62
|
+
d.dispatchEvent(new Event('visibilitychange'));
|
|
63
|
+
assert.equal(e.dataset.motion, 'off');
|
|
64
|
+
assert.equal(e.dataset.action, undefined);
|
|
65
|
+
assert.equal(runs[0].cancelled, true);
|
|
66
|
+
d.hidden = false;
|
|
67
|
+
d.dispatchEvent(new Event('visibilitychange'));
|
|
68
|
+
assert.equal(e.dataset.motion, 'idle');
|
|
69
|
+
assert.equal(runs.length, 1);
|
|
70
|
+
media.matches = true;
|
|
71
|
+
media.dispatchEvent(new Event('change'));
|
|
72
|
+
assert.equal(c.play('greet'), false);
|
|
73
|
+
assert.equal(e.dataset.motion, 'off');
|
|
74
|
+
media.matches = false;
|
|
75
|
+
media.dispatchEvent(new Event('change'));
|
|
76
|
+
c.configure({ enabled: false, resting: false });
|
|
77
|
+
assert.equal(c.play('celebrate'), false);
|
|
78
|
+
c.configure({ enabled: true, resting: false });
|
|
79
|
+
c.play('celebrate');
|
|
80
|
+
c.configure({ enabled: true, resting: true });
|
|
81
|
+
assert.equal(e.dataset.action, undefined);
|
|
82
|
+
assert.equal(e.dataset.motion, 'off');
|
|
83
|
+
assert.equal(c.play('greet'), true);
|
|
84
|
+
c.destroy();
|
|
85
|
+
d.dispatchEvent(new Event('visibilitychange'));
|
|
86
|
+
assert.equal(e.dataset.motion, 'off');
|
|
87
|
+
assert.equal(c.play('greet'), false);
|
|
88
|
+
await Promise.resolve();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('drag cancels reactions, release settles once, cancellation stays still', async () => {
|
|
92
|
+
const { controller: c, element: e, runs } = fixture();
|
|
93
|
+
c.play('greet');
|
|
94
|
+
c.drag(-20);
|
|
95
|
+
assert.equal(runs[0].cancelled, true);
|
|
96
|
+
assert.equal(e.dataset.action, 'drag');
|
|
97
|
+
assert.match(e.style.transform, /rotate\(-2deg\)/);
|
|
98
|
+
assert.equal(c.play('look'), false);
|
|
99
|
+
c.release();
|
|
100
|
+
assert.equal(e.style.transform, '');
|
|
101
|
+
assert.equal(e.dataset.action, 'settle');
|
|
102
|
+
c.drag(20);
|
|
103
|
+
c.release(true);
|
|
104
|
+
assert.equal(e.dataset.action, undefined);
|
|
105
|
+
assert.equal(runs.length, 2);
|
|
106
|
+
c.destroy();
|
|
107
|
+
await Promise.resolve();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('missing or failed animation API retains usable static artwork', () => {
|
|
111
|
+
const { controller: c, element: e } = fixture();
|
|
112
|
+
e.animate = undefined;
|
|
113
|
+
assert.equal(c.play('greet'), false);
|
|
114
|
+
e.animate = () => {
|
|
115
|
+
throw new Error('unavailable');
|
|
116
|
+
};
|
|
117
|
+
assert.equal(c.play('greet'), false);
|
|
118
|
+
assert.equal(e.dataset.action, undefined);
|
|
119
|
+
assert.equal(c.play('__proto__'), false);
|
|
120
|
+
c.destroy();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('interactive artwork keeps its DOM identity across clock poses and drinking feedback', () => {
|
|
124
|
+
const h = (type, props) => ({ type, props });
|
|
125
|
+
for (const model of ['whale-dress', 'xiaoxie-original']) {
|
|
126
|
+
const render = (pose, token) =>
|
|
127
|
+
petArtwork(
|
|
128
|
+
h,
|
|
129
|
+
{ pose, animate: true },
|
|
130
|
+
132,
|
|
131
|
+
token,
|
|
132
|
+
model,
|
|
133
|
+
{ 'whale-dress': 'data:image/webp;base64,test' },
|
|
134
|
+
true,
|
|
135
|
+
);
|
|
136
|
+
assert.equal(render('calm', '').props.key, render('happy', 'drink-1').props.key);
|
|
137
|
+
assert.equal(render('calm', '').props['data-interactive'], true);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
@@ -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,337 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createPetVideoMotion, petVideoAlphaSupported } from '../pet-video.js';
|
|
4
|
+
|
|
5
|
+
const flush = async () => {
|
|
6
|
+
for (let i = 0; i < 5; i++) await Promise.resolve();
|
|
7
|
+
};
|
|
8
|
+
function fixture({ alpha = true, rejectPlay = false, idle = false } = {}) {
|
|
9
|
+
const media = Object.assign(new EventTarget(), { matches: false });
|
|
10
|
+
const document = Object.assign(new EventTarget(), { hidden: false });
|
|
11
|
+
const videos = [],
|
|
12
|
+
timers = new Map(),
|
|
13
|
+
calls = [];
|
|
14
|
+
let nextTimer = 0;
|
|
15
|
+
document.createElement = (tag) => {
|
|
16
|
+
if (tag === 'canvas')
|
|
17
|
+
return {
|
|
18
|
+
getContext: () => ({
|
|
19
|
+
drawImage() {},
|
|
20
|
+
getImageData: (x) => ({ data: [0, 0, 0, x === 1 || !alpha ? 255 : 0] }),
|
|
21
|
+
}),
|
|
22
|
+
};
|
|
23
|
+
const video = {
|
|
24
|
+
style: {},
|
|
25
|
+
paused: false,
|
|
26
|
+
duration: 5,
|
|
27
|
+
removed: false,
|
|
28
|
+
canPlayType: () => 'probably',
|
|
29
|
+
setAttribute() {},
|
|
30
|
+
removeAttribute(name) {
|
|
31
|
+
delete this[name];
|
|
32
|
+
},
|
|
33
|
+
pause() {
|
|
34
|
+
this.paused = true;
|
|
35
|
+
},
|
|
36
|
+
load() {
|
|
37
|
+
if (this.src === 'probe') queueMicrotask(() => this.onloadeddata?.());
|
|
38
|
+
},
|
|
39
|
+
play() {
|
|
40
|
+
return rejectPlay ? Promise.reject(new Error('blocked')) : Promise.resolve();
|
|
41
|
+
},
|
|
42
|
+
remove() {
|
|
43
|
+
this.removed = true;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
videos.push(video);
|
|
47
|
+
return video;
|
|
48
|
+
};
|
|
49
|
+
const host = {
|
|
50
|
+
document,
|
|
51
|
+
matchMedia: () => media,
|
|
52
|
+
setTimeout(fn, ms) {
|
|
53
|
+
const id = ++nextTimer;
|
|
54
|
+
timers.set(id, { fn, ms });
|
|
55
|
+
return id;
|
|
56
|
+
},
|
|
57
|
+
clearTimeout(id) {
|
|
58
|
+
timers.delete(id);
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
const element = { dataset: {}, querySelector: () => ({ appendChild() {} }) };
|
|
62
|
+
const base = {
|
|
63
|
+
play(action) {
|
|
64
|
+
calls.push(action);
|
|
65
|
+
return true;
|
|
66
|
+
},
|
|
67
|
+
configure() {},
|
|
68
|
+
release() {
|
|
69
|
+
delete element.dataset.action;
|
|
70
|
+
},
|
|
71
|
+
drag() {
|
|
72
|
+
element.dataset.action = 'drag';
|
|
73
|
+
},
|
|
74
|
+
destroy() {
|
|
75
|
+
calls.push('destroy');
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
const motion = createPetVideoMotion(
|
|
79
|
+
element,
|
|
80
|
+
host,
|
|
81
|
+
base,
|
|
82
|
+
{ greet: 'greet-data', celebrate: 'drink-data', ...(idle ? { idle: 'idle-data' } : {}) },
|
|
83
|
+
'probe',
|
|
84
|
+
);
|
|
85
|
+
motion.configure({ enabled: true, resting: false });
|
|
86
|
+
return { host, document, media, element, base, motion, videos, timers, calls };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
test('alpha support checks decoded pixels rather than trusting WebM codec support', async () => {
|
|
90
|
+
for (const alpha of [true, false]) {
|
|
91
|
+
const f = fixture({ alpha });
|
|
92
|
+
assert.equal(await petVideoAlphaSupported(f.host, 'probe'), alpha);
|
|
93
|
+
assert.equal(f.videos.length, 1);
|
|
94
|
+
assert.equal(f.videos[0].src, undefined);
|
|
95
|
+
assert.equal(f.timers.size, 0);
|
|
96
|
+
f.motion.destroy();
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('clips reveal only after playing; accepted drinking interrupts greeting and stale events cannot stop it', async () => {
|
|
101
|
+
const f = fixture();
|
|
102
|
+
f.motion.play('greet');
|
|
103
|
+
await flush();
|
|
104
|
+
const greet = f.videos.at(-1),
|
|
105
|
+
staleEnd = greet.onended;
|
|
106
|
+
assert.equal(f.element.dataset.videoPlaying, undefined);
|
|
107
|
+
greet.onplaying();
|
|
108
|
+
assert.equal(f.element.dataset.videoPlaying, 'true');
|
|
109
|
+
assert.equal(greet.muted, true);
|
|
110
|
+
assert.equal(greet.playsInline, true);
|
|
111
|
+
assert.equal(f.motion.play('greet'), false);
|
|
112
|
+
f.motion.play('celebrate');
|
|
113
|
+
await flush();
|
|
114
|
+
const drink = f.videos.at(-1);
|
|
115
|
+
drink.onplaying();
|
|
116
|
+
assert.equal(greet.removed, true);
|
|
117
|
+
staleEnd();
|
|
118
|
+
assert.equal(f.element.dataset.action, 'celebrate');
|
|
119
|
+
assert.equal(f.motion.play('greet'), false);
|
|
120
|
+
assert.equal(f.motion.play('look'), false);
|
|
121
|
+
drink.onended();
|
|
122
|
+
assert.equal(f.element.dataset.videoPlaying, undefined);
|
|
123
|
+
assert.equal(f.element.dataset.action, undefined);
|
|
124
|
+
for (const timer of [...f.timers.values()]) if (timer.ms === 150) timer.fn();
|
|
125
|
+
assert.equal(drink.removed, true);
|
|
126
|
+
f.motion.destroy();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('motion changes and disposal cancel pending probes without late playback', async () => {
|
|
130
|
+
const f = fixture();
|
|
131
|
+
f.motion.play('greet');
|
|
132
|
+
f.motion.configure({ enabled: false, resting: false });
|
|
133
|
+
await flush();
|
|
134
|
+
assert.equal(f.videos.length, 1);
|
|
135
|
+
assert.equal(f.element.dataset.action, undefined);
|
|
136
|
+
f.motion.configure({ enabled: true, resting: false });
|
|
137
|
+
f.motion.play('greet');
|
|
138
|
+
await flush();
|
|
139
|
+
const oldPlaying = f.videos.at(-1).onplaying;
|
|
140
|
+
f.motion.destroy();
|
|
141
|
+
oldPlaying();
|
|
142
|
+
assert.equal(f.element.dataset.videoPlaying, undefined);
|
|
143
|
+
assert.equal(f.timers.size, 0);
|
|
144
|
+
assert.equal(f.motion.play('greet'), false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('background, reduced motion, quiet time and dragging stop media without replay', async () => {
|
|
148
|
+
const f = fixture();
|
|
149
|
+
await flush();
|
|
150
|
+
for (const kind of ['hidden', 'reduce', 'quiet', 'drag']) {
|
|
151
|
+
f.motion.configure({ enabled: true, resting: false });
|
|
152
|
+
f.motion.play('celebrate');
|
|
153
|
+
await flush();
|
|
154
|
+
const video = f.videos.at(-1);
|
|
155
|
+
video.onplaying();
|
|
156
|
+
if (kind === 'hidden') {
|
|
157
|
+
f.document.hidden = true;
|
|
158
|
+
f.document.dispatchEvent(new Event('visibilitychange'));
|
|
159
|
+
}
|
|
160
|
+
if (kind === 'reduce') {
|
|
161
|
+
f.media.matches = true;
|
|
162
|
+
f.media.dispatchEvent(new Event('change'));
|
|
163
|
+
}
|
|
164
|
+
if (kind === 'quiet') f.motion.configure({ enabled: true, resting: true });
|
|
165
|
+
if (kind === 'drag') f.motion.drag(10);
|
|
166
|
+
assert.equal(video.removed, true);
|
|
167
|
+
assert.equal(f.element.dataset.videoPlaying, undefined);
|
|
168
|
+
const count = f.videos.length;
|
|
169
|
+
f.document.hidden = false;
|
|
170
|
+
f.media.matches = false;
|
|
171
|
+
f.document.dispatchEvent(new Event('visibilitychange'));
|
|
172
|
+
f.media.dispatchEvent(new Event('change'));
|
|
173
|
+
f.motion.release(true);
|
|
174
|
+
await flush();
|
|
175
|
+
assert.equal(f.videos.length, count);
|
|
176
|
+
}
|
|
177
|
+
f.motion.destroy();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('unsupported alpha, rejected playback and a stalled clip fall back without repeatedly loading bad media', async () => {
|
|
181
|
+
for (const options of [{ alpha: false }, { rejectPlay: true }, {}]) {
|
|
182
|
+
const f = fixture(options);
|
|
183
|
+
f.motion.play('greet');
|
|
184
|
+
await flush();
|
|
185
|
+
if (!options.alpha && options.alpha !== false && !options.rejectPlay) {
|
|
186
|
+
const watchdog = [...f.timers.values()].find((t) => t.ms === 9000);
|
|
187
|
+
watchdog.fn();
|
|
188
|
+
await flush();
|
|
189
|
+
}
|
|
190
|
+
assert.deepEqual(f.calls, ['greet']);
|
|
191
|
+
assert.equal(f.element.dataset.videoPlaying, undefined);
|
|
192
|
+
const count = f.videos.length;
|
|
193
|
+
f.motion.play('greet');
|
|
194
|
+
await flush();
|
|
195
|
+
assert.equal(f.videos.length, count);
|
|
196
|
+
assert.deepEqual(f.calls, ['greet', 'greet']);
|
|
197
|
+
f.motion.destroy();
|
|
198
|
+
assert.equal(f.timers.size, 0);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
async function fireTimer(f, milliseconds) {
|
|
203
|
+
const found = [...f.timers].find(([, timer]) => timer.ms === milliseconds);
|
|
204
|
+
assert.ok(found, `Expected ${milliseconds}ms timer`);
|
|
205
|
+
f.timers.delete(found[0]);
|
|
206
|
+
found[1].fn();
|
|
207
|
+
await flush();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
test('idle loops are interrupted by actions and resume once after action completion', async () => {
|
|
211
|
+
const f = fixture({ idle: true });
|
|
212
|
+
await flush();
|
|
213
|
+
const idle = f.videos.at(-1);
|
|
214
|
+
idle.onplaying();
|
|
215
|
+
assert.equal(idle.loop, true);
|
|
216
|
+
assert.equal(f.timers.size, 0);
|
|
217
|
+
assert.equal(f.motion.play('look'), false);
|
|
218
|
+
f.motion.play('greet');
|
|
219
|
+
await flush();
|
|
220
|
+
const greet = f.videos.at(-1);
|
|
221
|
+
greet.onplaying();
|
|
222
|
+
assert.equal(idle.removed, true);
|
|
223
|
+
assert.equal(greet.loop, false);
|
|
224
|
+
const oldEnd = greet.onended;
|
|
225
|
+
f.motion.play('celebrate');
|
|
226
|
+
await flush();
|
|
227
|
+
const drink = f.videos.at(-1);
|
|
228
|
+
drink.duration = 7.5;
|
|
229
|
+
drink.onplaying();
|
|
230
|
+
assert.ok([...f.timers.values()].some((t) => t.ms === 9500));
|
|
231
|
+
oldEnd();
|
|
232
|
+
assert.equal(f.element.dataset.action, 'celebrate');
|
|
233
|
+
drink.onended();
|
|
234
|
+
await fireTimer(f, 0);
|
|
235
|
+
assert.equal(f.videos.at(-1).src, 'idle-data');
|
|
236
|
+
assert.equal(f.videos.at(-1).loop, true);
|
|
237
|
+
f.motion.destroy();
|
|
238
|
+
assert.equal(f.timers.size, 0);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('returning from hidden or reduced-motion state starts idle rather than replaying an action', async () => {
|
|
242
|
+
const f = fixture({ idle: true });
|
|
243
|
+
await flush();
|
|
244
|
+
f.motion.play('greet');
|
|
245
|
+
await flush();
|
|
246
|
+
f.document.hidden = true;
|
|
247
|
+
f.document.dispatchEvent(new Event('visibilitychange'));
|
|
248
|
+
assert.equal(f.element.dataset.action, undefined);
|
|
249
|
+
f.document.hidden = false;
|
|
250
|
+
f.document.dispatchEvent(new Event('visibilitychange'));
|
|
251
|
+
await fireTimer(f, 0);
|
|
252
|
+
assert.equal(f.videos.at(-1).src, 'idle-data');
|
|
253
|
+
f.media.matches = true;
|
|
254
|
+
f.media.dispatchEvent(new Event('change'));
|
|
255
|
+
const count = f.videos.length;
|
|
256
|
+
f.motion.configure({ enabled: true, resting: false });
|
|
257
|
+
await flush();
|
|
258
|
+
assert.equal(f.videos.length, count);
|
|
259
|
+
f.media.matches = false;
|
|
260
|
+
f.media.dispatchEvent(new Event('change'));
|
|
261
|
+
await fireTimer(f, 0);
|
|
262
|
+
assert.equal(f.videos.at(-1).src, 'idle-data');
|
|
263
|
+
f.motion.destroy();
|
|
264
|
+
assert.equal(f.timers.size, 0);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('quiet state and a held drag suppress idle until they finish', async () => {
|
|
268
|
+
const f = fixture({ idle: true });
|
|
269
|
+
await flush();
|
|
270
|
+
f.motion.configure({ enabled: true, resting: true });
|
|
271
|
+
const count = f.videos.length;
|
|
272
|
+
await flush();
|
|
273
|
+
assert.equal(f.videos.length, count);
|
|
274
|
+
f.motion.play('greet');
|
|
275
|
+
await flush();
|
|
276
|
+
f.videos.at(-1).onended();
|
|
277
|
+
await fireTimer(f, 0);
|
|
278
|
+
assert.equal(f.element.dataset.action, undefined);
|
|
279
|
+
f.motion.configure({ enabled: true, resting: false });
|
|
280
|
+
await flush();
|
|
281
|
+
f.motion.drag(1);
|
|
282
|
+
const before = f.videos.length;
|
|
283
|
+
f.motion.configure({ enabled: true, resting: false });
|
|
284
|
+
await flush();
|
|
285
|
+
assert.equal(f.videos.length, before);
|
|
286
|
+
assert.equal(f.element.dataset.action, 'drag');
|
|
287
|
+
f.motion.release(false);
|
|
288
|
+
await fireTimer(f, 650);
|
|
289
|
+
assert.equal(f.videos.at(-1).src, 'idle-data');
|
|
290
|
+
f.motion.destroy();
|
|
291
|
+
assert.equal(f.timers.size, 0);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('a stalled idle clip fails closed without disabling usable action clips', async () => {
|
|
295
|
+
const f = fixture({ idle: true });
|
|
296
|
+
await flush();
|
|
297
|
+
const idle = f.videos.at(-1);
|
|
298
|
+
idle.onplaying();
|
|
299
|
+
idle.onwaiting();
|
|
300
|
+
await fireTimer(f, 4000);
|
|
301
|
+
assert.equal(idle.removed, true);
|
|
302
|
+
const count = f.videos.length;
|
|
303
|
+
f.motion.configure({ enabled: true, resting: false });
|
|
304
|
+
await flush();
|
|
305
|
+
assert.equal(f.videos.length, count);
|
|
306
|
+
f.motion.play('greet');
|
|
307
|
+
await flush();
|
|
308
|
+
const greet = f.videos.at(-1);
|
|
309
|
+
greet.onplaying();
|
|
310
|
+
greet.onended();
|
|
311
|
+
await flush();
|
|
312
|
+
assert.equal(f.element.dataset.action, undefined);
|
|
313
|
+
f.motion.destroy();
|
|
314
|
+
assert.equal(f.timers.size, 0);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test('failed action fallback suspends idle during CSS feedback and then returns to idle', async () => {
|
|
318
|
+
const f = fixture({ idle: true });
|
|
319
|
+
await flush();
|
|
320
|
+
f.motion.play('greet');
|
|
321
|
+
await flush();
|
|
322
|
+
f.videos.at(-1).onerror();
|
|
323
|
+
assert.deepEqual(f.calls, ['greet']);
|
|
324
|
+
await fireTimer(f, 1400);
|
|
325
|
+
const idle = f.videos.at(-1);
|
|
326
|
+
idle.onplaying();
|
|
327
|
+
const count = f.videos.length;
|
|
328
|
+
f.motion.play('greet');
|
|
329
|
+
await flush();
|
|
330
|
+
assert.equal(idle.removed, true);
|
|
331
|
+
assert.equal(f.videos.length, count);
|
|
332
|
+
assert.deepEqual(f.calls, ['greet', 'greet']);
|
|
333
|
+
await fireTimer(f, 1400);
|
|
334
|
+
assert.equal(f.videos.at(-1).src, 'idle-data');
|
|
335
|
+
f.motion.destroy();
|
|
336
|
+
assert.equal(f.timers.size, 0);
|
|
337
|
+
});
|
|
@@ -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
|
-
{
|
|
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
|
});
|