@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.
Files changed (85) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +18 -11
  3. package/README.zh.md +18 -11
  4. package/assets/whale/pet.json +9 -0
  5. package/lib/client.js +394 -58
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +1007 -43
  8. package/lib/invariant.js +1 -1
  9. package/lib/{state-Diiq8vjk.js → state-DrMX22GL.js} +86 -28
  10. package/lib/types/affinity.d.ts +4 -0
  11. package/lib/types/affinity.d.ts.map +1 -1
  12. package/lib/types/affinity.js +32 -6
  13. package/lib/types/chatter.d.ts +111 -0
  14. package/lib/types/chatter.d.ts.map +1 -0
  15. package/lib/types/chatter.js +756 -0
  16. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  17. package/lib/types/client/PetDockEntry.js +1 -1
  18. package/lib/types/client/PetSprite.d.ts.map +1 -1
  19. package/lib/types/client/PetSprite.js +133 -16
  20. package/lib/types/client/PluginSettingsCard.d.ts +28 -0
  21. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  22. package/lib/types/client/PluginSettingsCard.js +147 -6
  23. package/lib/types/client/index.d.ts.map +1 -1
  24. package/lib/types/client/index.js +1 -0
  25. package/lib/types/client/locales.d.ts +4 -0
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/locales.js +4 -0
  28. package/lib/types/client/sequences.d.ts +10 -0
  29. package/lib/types/client/sequences.d.ts.map +1 -0
  30. package/lib/types/client/sequences.js +20 -0
  31. package/lib/types/event-projection.d.ts +14 -1
  32. package/lib/types/event-projection.d.ts.map +1 -1
  33. package/lib/types/event-projection.js +38 -17
  34. package/lib/types/ledger.d.ts.map +1 -1
  35. package/lib/types/ledger.js +15 -7
  36. package/lib/types/loopback.d.ts +25 -0
  37. package/lib/types/loopback.d.ts.map +1 -0
  38. package/lib/types/loopback.js +68 -0
  39. package/lib/types/persist.d.ts.map +1 -1
  40. package/lib/types/persist.js +3 -1
  41. package/lib/types/registry.d.ts +7 -1
  42. package/lib/types/registry.d.ts.map +1 -1
  43. package/lib/types/registry.js +47 -3
  44. package/lib/types/remarks.d.ts +2 -0
  45. package/lib/types/remarks.d.ts.map +1 -1
  46. package/lib/types/remarks.js +35 -0
  47. package/lib/types/routes.d.ts.map +1 -1
  48. package/lib/types/routes.js +14 -0
  49. package/lib/types/service.d.ts +6 -0
  50. package/lib/types/service.d.ts.map +1 -1
  51. package/lib/types/service.js +15 -2
  52. package/lib/types/state.d.ts +7 -4
  53. package/lib/types/state.d.ts.map +1 -1
  54. package/lib/types/state.js +20 -20
  55. package/package.json +9 -9
  56. package/src/affinity.test.ts +4 -2
  57. package/src/affinity.ts +37 -6
  58. package/src/chatter.test.ts +154 -0
  59. package/src/chatter.ts +787 -0
  60. package/src/client/PetDockEntry.test.tsx +93 -0
  61. package/src/client/PetDockEntry.tsx +1 -0
  62. package/src/client/PetSprite.test.tsx +253 -3
  63. package/src/client/PetSprite.tsx +192 -37
  64. package/src/client/PluginSettingsCard.tsx +229 -18
  65. package/src/client/index.test.tsx +95 -0
  66. package/src/client/index.ts +1 -0
  67. package/src/client/locales.ts +4 -0
  68. package/src/client/pet-css.test.ts +54 -0
  69. package/src/client/pet.module.css +215 -57
  70. package/src/client/sequences.test.ts +33 -0
  71. package/src/client/sequences.ts +33 -0
  72. package/src/client/settings-card.module.css +90 -1
  73. package/src/event-projection.ts +49 -16
  74. package/src/ledger.test.ts +42 -1
  75. package/src/ledger.ts +15 -7
  76. package/src/loopback.ts +63 -0
  77. package/src/persist.test.ts +18 -1
  78. package/src/persist.ts +3 -1
  79. package/src/registry.test.ts +79 -4
  80. package/src/registry.ts +58 -6
  81. package/src/remarks.ts +36 -0
  82. package/src/routes.ts +11 -0
  83. package/src/service.ts +27 -2
  84. package/src/state.test.ts +13 -0
  85. 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
+ })
@@ -106,6 +106,7 @@ export function PetDockEntry(props: PetDockEntryProps): ReactElement {
106
106
  }}
107
107
  onClick={props.summon}
108
108
  data-testid="pet-summon"
109
+ data-dsh-part="summon-button"
109
110
  >
110
111
  {props.t('pet.summon', { name: snapshot?.name ?? '' })}
111
112
  </button>
@@ -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
- it('renders one bubble per concurrent session without duplicating the global bubble', () => {
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
  })