@linxin666/dsh-pet 0.1.20 → 0.2.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/README.i18n.yaml +2 -2
- package/README.md +18 -11
- package/README.zh.md +18 -11
- package/assets/whale/pet.json +9 -0
- package/lib/client.js +394 -58
- package/lib/client.js.map +1 -1
- package/lib/index.js +1007 -43
- package/lib/invariant.js +1 -1
- package/lib/{state-Diiq8vjk.js → state-DrMX22GL.js} +86 -28
- package/lib/types/affinity.d.ts +4 -0
- package/lib/types/affinity.d.ts.map +1 -1
- package/lib/types/affinity.js +32 -6
- package/lib/types/chatter.d.ts +111 -0
- package/lib/types/chatter.d.ts.map +1 -0
- package/lib/types/chatter.js +756 -0
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +1 -1
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +133 -16
- package/lib/types/client/PluginSettingsCard.d.ts +28 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +147 -6
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +1 -0
- package/lib/types/client/locales.d.ts +4 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +4 -0
- package/lib/types/client/sequences.d.ts +10 -0
- package/lib/types/client/sequences.d.ts.map +1 -0
- package/lib/types/client/sequences.js +20 -0
- package/lib/types/event-projection.d.ts +14 -1
- package/lib/types/event-projection.d.ts.map +1 -1
- package/lib/types/event-projection.js +38 -17
- package/lib/types/ledger.d.ts.map +1 -1
- package/lib/types/ledger.js +15 -7
- package/lib/types/loopback.d.ts +25 -0
- package/lib/types/loopback.d.ts.map +1 -0
- package/lib/types/loopback.js +68 -0
- package/lib/types/persist.d.ts.map +1 -1
- package/lib/types/persist.js +3 -1
- package/lib/types/registry.d.ts +7 -1
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +47 -3
- package/lib/types/remarks.d.ts +2 -0
- package/lib/types/remarks.d.ts.map +1 -1
- package/lib/types/remarks.js +35 -0
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +14 -0
- package/lib/types/service.d.ts +6 -0
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +15 -2
- package/lib/types/state.d.ts +7 -4
- package/lib/types/state.d.ts.map +1 -1
- package/lib/types/state.js +20 -20
- package/package.json +9 -9
- package/src/affinity.test.ts +4 -2
- package/src/affinity.ts +37 -6
- package/src/chatter.test.ts +154 -0
- package/src/chatter.ts +787 -0
- package/src/client/PetDockEntry.test.tsx +93 -0
- package/src/client/PetDockEntry.tsx +1 -0
- package/src/client/PetSprite.test.tsx +253 -3
- package/src/client/PetSprite.tsx +192 -37
- package/src/client/PluginSettingsCard.tsx +229 -18
- package/src/client/index.test.tsx +95 -0
- package/src/client/index.ts +1 -0
- package/src/client/locales.ts +4 -0
- package/src/client/pet-css.test.ts +54 -0
- package/src/client/pet.module.css +215 -57
- package/src/client/sequences.test.ts +33 -0
- package/src/client/sequences.ts +33 -0
- package/src/client/settings-card.module.css +90 -1
- package/src/event-projection.ts +49 -16
- package/src/ledger.test.ts +42 -1
- package/src/ledger.ts +15 -7
- package/src/loopback.ts +63 -0
- package/src/persist.test.ts +18 -1
- package/src/persist.ts +3 -1
- package/src/registry.test.ts +79 -4
- package/src/registry.ts +58 -6
- package/src/remarks.ts +36 -0
- package/src/routes.ts +11 -0
- package/src/service.ts +27 -2
- package/src/state.test.ts +13 -0
- package/src/state.ts +24 -18
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* The hidden-state summon button opts into the L2 semantic attributes
|
|
4
|
+
* (issue #506): it carries data-dsh-part="summon-button" so skins can target
|
|
5
|
+
* it without hash-class selectors.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
|
8
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
9
|
+
// The npm SDK's client half is a closure-factory bundle for the GUI's
|
|
10
|
+
// __ModuleLoader__ (not importable under vitest); provide the defineStore
|
|
11
|
+
// the pet store needs (same fake-store pattern as the settings-card tests).
|
|
12
|
+
vi.mock('@deepseek-ai/dsh-client-runtime/client', () => ({
|
|
13
|
+
defineStore: (spec: {
|
|
14
|
+
init: () => unknown
|
|
15
|
+
actions: Record<string, (draft: never, ...args: never[]) => void>
|
|
16
|
+
}) => ({
|
|
17
|
+
create: () => {
|
|
18
|
+
let value = spec.init()
|
|
19
|
+
const listeners = new Set<() => void>()
|
|
20
|
+
const actions: Record<string, (...args: unknown[]) => void> = {}
|
|
21
|
+
for (const [name, fn] of Object.entries(spec.actions)) {
|
|
22
|
+
actions[name] = (...args: unknown[]) => {
|
|
23
|
+
fn(value as never, ...(args as never[]))
|
|
24
|
+
for (const listener of listeners) listener()
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
getSnapshot: () => value,
|
|
29
|
+
subscribe: (listener: () => void) => {
|
|
30
|
+
listeners.add(listener)
|
|
31
|
+
return () => { listeners.delete(listener) }
|
|
32
|
+
},
|
|
33
|
+
actions,
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
}))
|
|
38
|
+
import { PetDockEntry, type PetInjected } from './PetDockEntry.tsx'
|
|
39
|
+
import { createPetStore } from './pet-store.ts'
|
|
40
|
+
import { t } from './locales.ts'
|
|
41
|
+
import type { PetStateView } from '../service.ts'
|
|
42
|
+
|
|
43
|
+
beforeAll(() => {
|
|
44
|
+
document.documentElement.lang = 'zh'
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
afterEach(cleanup)
|
|
48
|
+
|
|
49
|
+
/** Snapshot fixture with the pet hidden (the summon-button state). */
|
|
50
|
+
const hiddenSnapshot: PetStateView = {
|
|
51
|
+
animation: 'idle',
|
|
52
|
+
phase: 'idle',
|
|
53
|
+
sessionActive: false,
|
|
54
|
+
affinity: {
|
|
55
|
+
points: 0,
|
|
56
|
+
rank: '幼鲸',
|
|
57
|
+
rankEmoji: '*',
|
|
58
|
+
pets: 0,
|
|
59
|
+
feeds: 0,
|
|
60
|
+
turns: 0,
|
|
61
|
+
petCooldown: false,
|
|
62
|
+
feedCooldown: false,
|
|
63
|
+
},
|
|
64
|
+
display: { visible: false, size: 160, right: 24, bottom: 20 },
|
|
65
|
+
pet: { id: 'whale-girl', displayName: '鲸鱼娘', description: '测试用鲸鱼娘' },
|
|
66
|
+
name: '泡泡',
|
|
67
|
+
treats: { stocked: 0, max: 5 },
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function injected(): PetInjected {
|
|
71
|
+
return {
|
|
72
|
+
store: createPetStore().create(),
|
|
73
|
+
ensure: vi.fn(),
|
|
74
|
+
pet: vi.fn(),
|
|
75
|
+
feed: vi.fn(),
|
|
76
|
+
hide: vi.fn(),
|
|
77
|
+
summon: vi.fn(),
|
|
78
|
+
dragEnd: vi.fn(),
|
|
79
|
+
rename: vi.fn(),
|
|
80
|
+
openSession: vi.fn(),
|
|
81
|
+
feedbackDone: vi.fn(),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe('PetDockEntry L2 semantic attributes (#506)', () => {
|
|
86
|
+
it('tags the summon button as the summon-button part', () => {
|
|
87
|
+
const props = injected()
|
|
88
|
+
props.store.actions.setSnapshot(hiddenSnapshot)
|
|
89
|
+
render(<PetDockEntry {...props} t={t} />)
|
|
90
|
+
const summon = screen.getByTestId('pet-summon')
|
|
91
|
+
expect(summon.getAttribute('data-dsh-part')).toBe('summon-button')
|
|
92
|
+
})
|
|
93
|
+
})
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* selection) as composition input, never as submit/cancel (issue #89).
|
|
6
6
|
*/
|
|
7
7
|
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
|
8
|
-
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
8
|
+
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
9
9
|
import { PetSprite, type PetSpriteProps } from './PetSprite.tsx'
|
|
10
10
|
import { t } from './locales.ts'
|
|
11
11
|
import type { PetStateView } from '../service.ts'
|
|
@@ -27,6 +27,7 @@ function petDefinition(): PetDefinition {
|
|
|
27
27
|
cell: { width: 192, height: 208 },
|
|
28
28
|
columns: 8,
|
|
29
29
|
rows: [6, 8, 8, 4, 5, 8, 6, 6, 6],
|
|
30
|
+
atlasRows: 9,
|
|
30
31
|
tracks: {
|
|
31
32
|
idle: track([0, 1, 2, 3, 4, 5], [400, 400, 400, 400, 400, 400]),
|
|
32
33
|
'running-right': track([0, 1, 2, 3, 4, 5, 6, 7], [225, 225, 225, 225, 225, 225, 225, 225]),
|
|
@@ -86,6 +87,7 @@ beforeAll(() => {
|
|
|
86
87
|
|
|
87
88
|
afterEach(() => {
|
|
88
89
|
cleanup()
|
|
90
|
+
vi.restoreAllMocks()
|
|
89
91
|
})
|
|
90
92
|
|
|
91
93
|
/** Render the pet with mocked callbacks; returns the rename and open spys. */
|
|
@@ -176,6 +178,129 @@ describe('PetSprite rename input', () => {
|
|
|
176
178
|
expect(onRename).not.toHaveBeenCalled()
|
|
177
179
|
expect(screen.queryByPlaceholderText('输入新名字')).toBeNull()
|
|
178
180
|
})
|
|
181
|
+
|
|
182
|
+
it('ignores Enter between compositionStart and compositionEnd even when isComposing is false (#303)', () => {
|
|
183
|
+
// WeChat IME (Windows) marks composition keydowns with isComposing ===
|
|
184
|
+
// false; only the explicit composition events can be trusted.
|
|
185
|
+
const { onRename } = renderPet()
|
|
186
|
+
const input = openRename()
|
|
187
|
+
fireEvent.change(input, { target: { value: '泡泡酱' } })
|
|
188
|
+
fireEvent.compositionStart(input)
|
|
189
|
+
fireEvent.keyDown(input, { key: 'Enter' })
|
|
190
|
+
expect(onRename).not.toHaveBeenCalled()
|
|
191
|
+
expect(screen.getByPlaceholderText('输入新名字')).toBe(input)
|
|
192
|
+
fireEvent.compositionEnd(input)
|
|
193
|
+
fireEvent.keyDown(input, { key: 'Enter' })
|
|
194
|
+
expect(onRename).toHaveBeenCalledWith('泡泡酱')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it("ignores 'Process' keydowns emitted by IMEs mid-composition (#303)", () => {
|
|
198
|
+
const { onRename } = renderPet()
|
|
199
|
+
const input = openRename()
|
|
200
|
+
fireEvent.change(input, { target: { value: '泡泡酱' } })
|
|
201
|
+
fireEvent.keyDown(input, { key: 'Process' })
|
|
202
|
+
expect(onRename).not.toHaveBeenCalled()
|
|
203
|
+
expect(screen.getByPlaceholderText('输入新名字')).toBe(input)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('keeps the rename panel open when the pointer leaves mid-rename (#303)', () => {
|
|
207
|
+
// An IME candidate window is an OS-level window: moving the pointer onto
|
|
208
|
+
// it fires pointerleave on the float. The hide timer must not unmount
|
|
209
|
+
// the input mid-composition (that crashes some IMEs / the renderer).
|
|
210
|
+
vi.useFakeTimers()
|
|
211
|
+
try {
|
|
212
|
+
renderPet()
|
|
213
|
+
const input = openRename()
|
|
214
|
+
const float = input.parentElement?.parentElement?.parentElement
|
|
215
|
+
expect(float).not.toBeNull()
|
|
216
|
+
fireEvent.pointerOut(float!, { relatedTarget: null })
|
|
217
|
+
act(() => { vi.advanceTimersByTime(1000) })
|
|
218
|
+
expect(screen.getByPlaceholderText('输入新名字')).toBe(input)
|
|
219
|
+
// After the rename ends, hover behavior works again.
|
|
220
|
+
fireEvent.keyDown(input, { key: 'Escape' })
|
|
221
|
+
fireEvent.pointerOut(float!, { relatedTarget: null })
|
|
222
|
+
act(() => { vi.advanceTimersByTime(1000) })
|
|
223
|
+
expect(screen.queryByText('改名')).toBeNull()
|
|
224
|
+
} finally {
|
|
225
|
+
vi.useRealTimers()
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
describe('PetSprite hover panel placement', () => {
|
|
231
|
+
it('places the panel above when an old saved position leaves no room below', () => {
|
|
232
|
+
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 900 })
|
|
233
|
+
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
|
234
|
+
if (this.style.bottom !== '') {
|
|
235
|
+
return { top: 720, right: 1256, bottom: 880, left: 1108, width: 148, height: 160, x: 1108, y: 720, toJSON: () => ({}) }
|
|
236
|
+
}
|
|
237
|
+
return { top: 888, right: 1259, bottom: 974, left: 1105, width: 154, height: 86, x: 1105, y: 888, toJSON: () => ({}) }
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
renderPet({ display: { ...snapshot.display, bottom: 20 } })
|
|
241
|
+
fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
|
|
242
|
+
|
|
243
|
+
const panel = screen.getByText('改名').closest('[data-placement]') as HTMLElement
|
|
244
|
+
expect(panel.getAttribute('data-placement')).toBe('above')
|
|
245
|
+
// No bubble above the sprite: the panel keeps its default 8px gap.
|
|
246
|
+
expect(panel.style.marginBottom).toBe('')
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('lifts the above-panel clear of the status bubble instead of overlapping it', () => {
|
|
250
|
+
// Regression: the fallback above-placement anchored the panel at the
|
|
251
|
+
// sprite's top edge — the exact region the status bubble occupies — so
|
|
252
|
+
// the panel covered the bubble. The panel must now ride above it.
|
|
253
|
+
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 900 })
|
|
254
|
+
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
|
255
|
+
if (this.className.includes('bubbleStack')) {
|
|
256
|
+
// The bubble area (stack wrapping the status bubble): 40px tall.
|
|
257
|
+
return { top: 650, right: 1200, bottom: 690, left: 1120, width: 80, height: 40, x: 1120, y: 650, toJSON: () => ({}) }
|
|
258
|
+
}
|
|
259
|
+
// Sprite (bottom 974 of a 900px viewport) and panel (86px tall).
|
|
260
|
+
return { top: 888, right: 1259, bottom: 974, left: 1105, width: 154, height: 86, x: 1105, y: 888, toJSON: () => ({}) }
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
renderPet({
|
|
264
|
+
snapshot: { ...snapshot, bubble: '正在思考' },
|
|
265
|
+
display: { ...snapshot.display, bottom: 20 },
|
|
266
|
+
})
|
|
267
|
+
fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
|
|
268
|
+
|
|
269
|
+
const panel = screen.getByText('改名').closest('[data-placement]') as HTMLElement
|
|
270
|
+
expect(panel.getAttribute('data-placement')).toBe('above')
|
|
271
|
+
// 40px bubble height + 14px (8px base gap + 6px clearance).
|
|
272
|
+
expect(panel.style.marginBottom).toBe('54px')
|
|
273
|
+
// The bubble stays rendered while the lifted panel is open.
|
|
274
|
+
expect(screen.queryByText('正在思考')).not.toBeNull()
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('lifts the above-panel clear of the session bubble stack', () => {
|
|
278
|
+
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 900 })
|
|
279
|
+
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
|
280
|
+
if (this.className.includes('bubbleStack')) {
|
|
281
|
+
// Two stacked bubbles: 88px tall.
|
|
282
|
+
return { top: 600, right: 1200, bottom: 688, left: 1100, width: 100, height: 88, x: 1100, y: 600, toJSON: () => ({}) }
|
|
283
|
+
}
|
|
284
|
+
return { top: 888, right: 1259, bottom: 974, left: 1105, width: 154, height: 86, x: 1105, y: 888, toJSON: () => ({}) }
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
renderPet({
|
|
288
|
+
snapshot: {
|
|
289
|
+
...snapshot,
|
|
290
|
+
sessions: [
|
|
291
|
+
{ sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考' },
|
|
292
|
+
{ sessionId: 's-b', animation: 'running-right', phase: 'tool', bubble: '正在使用 grep' },
|
|
293
|
+
],
|
|
294
|
+
},
|
|
295
|
+
display: { ...snapshot.display, bottom: 20 },
|
|
296
|
+
})
|
|
297
|
+
fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
|
|
298
|
+
|
|
299
|
+
const panel = screen.getByText('改名').closest('[data-placement]') as HTMLElement
|
|
300
|
+
expect(panel.getAttribute('data-placement')).toBe('above')
|
|
301
|
+
// 88px stack height + 14px clearance.
|
|
302
|
+
expect(panel.style.marginBottom).toBe('102px')
|
|
303
|
+
})
|
|
179
304
|
})
|
|
180
305
|
|
|
181
306
|
describe('PetSprite status bubble', () => {
|
|
@@ -200,7 +325,54 @@ describe('PetSprite status bubble', () => {
|
|
|
200
325
|
expect(screen.queryByText('正在思考')).toBeNull()
|
|
201
326
|
})
|
|
202
327
|
|
|
203
|
-
|
|
328
|
+
/** Expand a collapsed session stack by hovering its bubble area. */
|
|
329
|
+
function expandStack(): void {
|
|
330
|
+
const stack = screen.getByText('正在思考').closest('div')
|
|
331
|
+
expect(stack).not.toBeNull()
|
|
332
|
+
fireEvent.pointerOver(stack!)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
it('collapses concurrent sessions behind the display session bubble', () => {
|
|
336
|
+
renderPet({
|
|
337
|
+
snapshot: {
|
|
338
|
+
...workingSnapshot,
|
|
339
|
+
bubble: '正在思考',
|
|
340
|
+
sessions: [
|
|
341
|
+
{ sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考' },
|
|
342
|
+
{ sessionId: 's-b', animation: 'running-right', phase: 'tool', bubble: '正在使用 grep' },
|
|
343
|
+
],
|
|
344
|
+
},
|
|
345
|
+
})
|
|
346
|
+
// Only the display session speaks; the other session hides behind the
|
|
347
|
+
// '+1' badge so the pet never wears a tall bubble stack.
|
|
348
|
+
expect(screen.getAllByText('正在思考')).toHaveLength(1)
|
|
349
|
+
expect(screen.queryByText('正在使用 grep')).toBeNull()
|
|
350
|
+
expect(screen.queryByRole('button', { name: '展开其余 1 个会话的气泡' })).not.toBeNull()
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('expands the collapsed stack on hover and on badge tap', () => {
|
|
354
|
+
renderPet({
|
|
355
|
+
snapshot: {
|
|
356
|
+
...workingSnapshot,
|
|
357
|
+
sessions: [
|
|
358
|
+
{ sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考' },
|
|
359
|
+
{ sessionId: 's-b', animation: 'running-right', phase: 'tool', bubble: '正在使用 grep' },
|
|
360
|
+
],
|
|
361
|
+
},
|
|
362
|
+
})
|
|
363
|
+
// Hover peek: extras appear, and leaving collapses them again.
|
|
364
|
+
expandStack()
|
|
365
|
+
expect(screen.queryByText('正在使用 grep')).not.toBeNull()
|
|
366
|
+
fireEvent.pointerOut(screen.getByText('正在思考').closest('div')!)
|
|
367
|
+
expect(screen.queryByText('正在使用 grep')).toBeNull()
|
|
368
|
+
// Badge tap pins the stack open (touch path); tapping again collapses.
|
|
369
|
+
fireEvent.click(screen.getByRole('button', { name: '展开其余 1 个会话的气泡' }))
|
|
370
|
+
expect(screen.queryByText('正在使用 grep')).not.toBeNull()
|
|
371
|
+
fireEvent.click(screen.getByRole('button', { name: '收起会话气泡' }))
|
|
372
|
+
expect(screen.queryByText('正在使用 grep')).toBeNull()
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('renders one bubble per concurrent session in the expanded stack', () => {
|
|
204
376
|
renderPet({
|
|
205
377
|
snapshot: {
|
|
206
378
|
...workingSnapshot,
|
|
@@ -211,12 +383,54 @@ describe('PetSprite status bubble', () => {
|
|
|
211
383
|
],
|
|
212
384
|
},
|
|
213
385
|
})
|
|
386
|
+
expandStack()
|
|
214
387
|
// The display session appears in the stack exactly once: the legacy
|
|
215
388
|
// single bubble is not rendered on top of the session list.
|
|
216
389
|
expect(screen.getAllByText('正在思考')).toHaveLength(1)
|
|
217
390
|
expect(screen.queryByText('正在使用 grep')).not.toBeNull()
|
|
218
391
|
})
|
|
219
392
|
|
|
393
|
+
it('lets the inner whisper take over the status bubble', () => {
|
|
394
|
+
renderPet({ snapshot: { ...workingSnapshot, whisper: '哼哧哼哧,大脑转得飞快~' } })
|
|
395
|
+
// The whisper speaks THROUGH the bubble: it replaces (not accompanies)
|
|
396
|
+
// the status copy, so the pet never shows two bubbles at once.
|
|
397
|
+
expect(screen.queryByText('哼哧哼哧,大脑转得飞快~')).not.toBeNull()
|
|
398
|
+
expect(screen.queryByText('正在思考')).toBeNull()
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
it('lets the whisper take over the display session bubble in the stack', () => {
|
|
402
|
+
const { onOpenSession } = renderPet({
|
|
403
|
+
snapshot: {
|
|
404
|
+
...workingSnapshot,
|
|
405
|
+
whisper: '我在这儿陪着你呢,别急别急',
|
|
406
|
+
sessions: [
|
|
407
|
+
{ sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考' },
|
|
408
|
+
{ sessionId: 's-b', animation: 'running-right', phase: 'tool', bubble: '正在使用 grep' },
|
|
409
|
+
],
|
|
410
|
+
},
|
|
411
|
+
})
|
|
412
|
+
// The primary bubble (the display session) speaks the whisper instead
|
|
413
|
+
// of its status copy; the collapsed extra session hides behind the badge.
|
|
414
|
+
expect(screen.queryByText('我在这儿陪着你呢,别急别急')).not.toBeNull()
|
|
415
|
+
expect(screen.queryByText('正在思考')).toBeNull()
|
|
416
|
+
expect(screen.queryByText('正在使用 grep')).toBeNull()
|
|
417
|
+
// Expanding reveals the other session's own bubble, untouched.
|
|
418
|
+
fireEvent.pointerOver(screen.getByText('我在这儿陪着你呢,别急别急').closest('div')!)
|
|
419
|
+
expect(screen.queryByText('正在使用 grep')).not.toBeNull()
|
|
420
|
+
// The whisper-toned bubble stays clickable and still opens its session.
|
|
421
|
+
fireEvent.click(screen.getByText('我在这儿陪着你呢,别急别急'))
|
|
422
|
+
expect(onOpenSession).toHaveBeenCalledWith('s-a')
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
it('lets transient interaction feedback take over the whisper too', () => {
|
|
426
|
+
renderPet({
|
|
427
|
+
snapshot: { ...workingSnapshot, whisper: '哼哧哼哧,大脑转得飞快~' },
|
|
428
|
+
feedback: { text: '摸摸成功', kind: 'pet', at: 1 },
|
|
429
|
+
})
|
|
430
|
+
expect(screen.queryByText('摸摸成功')).not.toBeNull()
|
|
431
|
+
expect(screen.queryByText('哼哧哼哧,大脑转得飞快~')).toBeNull()
|
|
432
|
+
})
|
|
433
|
+
|
|
220
434
|
it('lets feedback replace the whole session bubble stack', () => {
|
|
221
435
|
renderPet({
|
|
222
436
|
snapshot: {
|
|
@@ -244,6 +458,8 @@ describe('PetSprite status bubble', () => {
|
|
|
244
458
|
],
|
|
245
459
|
},
|
|
246
460
|
})
|
|
461
|
+
// The collapsed stack must be expanded before its extras can be hit.
|
|
462
|
+
fireEvent.pointerOver(screen.getByText('正在思考').closest('div')!)
|
|
247
463
|
fireEvent.click(screen.getByText('正在使用 grep'))
|
|
248
464
|
expect(onOpenSession).toHaveBeenCalledTimes(1)
|
|
249
465
|
expect(onOpenSession).toHaveBeenCalledWith('s-b')
|
|
@@ -275,7 +491,9 @@ describe('PetSprite status bubble', () => {
|
|
|
275
491
|
fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
|
|
276
492
|
// The hover panel is open...
|
|
277
493
|
expect(screen.queryByText('改名')).not.toBeNull()
|
|
278
|
-
// ...and the bubbles are still there, still clickable
|
|
494
|
+
// ...and the bubbles are still there, still clickable once the collapsed
|
|
495
|
+
// stack is expanded by hovering it.
|
|
496
|
+
fireEvent.pointerOver(screen.getByText('正在思考').closest('div')!)
|
|
279
497
|
expect(screen.getByText('正在使用 grep')).not.toBeNull()
|
|
280
498
|
fireEvent.click(screen.getByText('正在使用 grep'))
|
|
281
499
|
expect(onOpenSession).toHaveBeenCalledWith('s-b')
|
|
@@ -293,4 +511,36 @@ describe('PetSprite definition-driven render', () => {
|
|
|
293
511
|
fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
|
|
294
512
|
expect(screen.queryByText('泡泡')).not.toBeNull()
|
|
295
513
|
})
|
|
514
|
+
|
|
515
|
+
it('advances a configured scene sequence after the current track duration', () => {
|
|
516
|
+
vi.spyOn(window, 'matchMedia').mockReturnValue({
|
|
517
|
+
matches: false,
|
|
518
|
+
media: '(prefers-reduced-motion: reduce)',
|
|
519
|
+
onchange: null,
|
|
520
|
+
addEventListener: () => {},
|
|
521
|
+
removeEventListener: () => {},
|
|
522
|
+
addListener: () => {},
|
|
523
|
+
removeListener: () => {},
|
|
524
|
+
dispatchEvent: () => false,
|
|
525
|
+
})
|
|
526
|
+
vi.spyOn(performance, 'now').mockReturnValue(0)
|
|
527
|
+
let nextFrame: FrameRequestCallback | undefined
|
|
528
|
+
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
|
529
|
+
nextFrame = callback
|
|
530
|
+
return 1
|
|
531
|
+
})
|
|
532
|
+
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
|
533
|
+
const definition = petDefinition()
|
|
534
|
+
definition.sequences = {
|
|
535
|
+
thinking: ['running', 'waiting', 'running', 'waiting', 'running'],
|
|
536
|
+
}
|
|
537
|
+
renderPet({
|
|
538
|
+
definition,
|
|
539
|
+
snapshot: { ...snapshot, animation: 'running', phase: 'thinking' },
|
|
540
|
+
})
|
|
541
|
+
const sprite = screen.getByRole('button', { name: '鲸鱼娘' })
|
|
542
|
+
expect(sprite.style.backgroundPosition).toBe('0px -1120px')
|
|
543
|
+
act(() => { nextFrame?.(1_500) })
|
|
544
|
+
expect(sprite.style.backgroundPosition).toBe('0px -960px')
|
|
545
|
+
})
|
|
296
546
|
})
|