@linxin666/dsh-pet 0.3.3 → 0.3.5

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 (123) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +30 -13
  3. package/README.zh.md +30 -13
  4. package/assets/ouo-neko/pet.json +31 -0
  5. package/assets/ouo-neko/previews/failed.gif +0 -0
  6. package/assets/ouo-neko/previews/idle.gif +0 -0
  7. package/assets/ouo-neko/previews/jumping.gif +0 -0
  8. package/assets/ouo-neko/previews/review.gif +0 -0
  9. package/assets/ouo-neko/previews/running-left.gif +0 -0
  10. package/assets/ouo-neko/previews/running-right.gif +0 -0
  11. package/assets/ouo-neko/previews/running.gif +0 -0
  12. package/assets/ouo-neko/previews/waiting.gif +0 -0
  13. package/assets/ouo-neko/previews/waving.gif +0 -0
  14. package/assets/ouo-neko/spritesheet.webp +0 -0
  15. package/contracts/pet-manifest-v2.schema.json +102 -1
  16. package/contracts/voice-pack-v1.schema.json +23 -28
  17. package/lib/client.js +781 -40
  18. package/lib/client.js.map +1 -1
  19. package/lib/index.js +1567 -482
  20. package/lib/types/chatter.d.ts +68 -46
  21. package/lib/types/chatter.d.ts.map +1 -1
  22. package/lib/types/chatter.js +243 -301
  23. package/lib/types/client/PetDockEntry.d.ts +3 -0
  24. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  25. package/lib/types/client/PetDockEntry.js +18 -2
  26. package/lib/types/client/PetSprite.d.ts +23 -0
  27. package/lib/types/client/PetSprite.d.ts.map +1 -1
  28. package/lib/types/client/PetSprite.js +49 -45
  29. package/lib/types/client/PluginSettingsCard.d.ts +2 -0
  30. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  31. package/lib/types/client/PluginSettingsCard.js +2 -2
  32. package/lib/types/client/drag-stream.d.ts +19 -0
  33. package/lib/types/client/drag-stream.d.ts.map +1 -0
  34. package/lib/types/client/drag-stream.js +26 -0
  35. package/lib/types/client/gameplay-hud.d.ts +50 -0
  36. package/lib/types/client/gameplay-hud.d.ts.map +1 -0
  37. package/lib/types/client/gameplay-hud.js +274 -0
  38. package/lib/types/client/index.d.ts.map +1 -1
  39. package/lib/types/client/index.js +38 -9
  40. package/lib/types/client/locales.d.ts +34 -0
  41. package/lib/types/client/locales.d.ts.map +1 -1
  42. package/lib/types/client/locales.js +36 -0
  43. package/lib/types/client/pet-store.d.ts +7 -1
  44. package/lib/types/client/pet-store.d.ts.map +1 -1
  45. package/lib/types/client/pet-store.js +4 -0
  46. package/lib/types/client/renderers/Frames2dVisualMount.d.ts +29 -0
  47. package/lib/types/client/renderers/Frames2dVisualMount.d.ts.map +1 -0
  48. package/lib/types/client/renderers/Frames2dVisualMount.js +83 -0
  49. package/lib/types/client/renderers/PetRendererSwitch.d.ts +6 -0
  50. package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -1
  51. package/lib/types/client/renderers/PetRendererSwitch.js +14 -1
  52. package/lib/types/client/renderers/frames2d.d.ts +36 -0
  53. package/lib/types/client/renderers/frames2d.d.ts.map +1 -0
  54. package/lib/types/client/renderers/frames2d.js +196 -0
  55. package/lib/types/defaults.d.ts +6 -0
  56. package/lib/types/defaults.d.ts.map +1 -0
  57. package/lib/types/defaults.js +5 -0
  58. package/lib/types/dsh-home.d.ts.map +1 -1
  59. package/lib/types/dsh-home.js +9 -3
  60. package/lib/types/event-projection.d.ts +9 -4
  61. package/lib/types/event-projection.d.ts.map +1 -1
  62. package/lib/types/event-projection.js +46 -14
  63. package/lib/types/gameplay.d.ts +182 -0
  64. package/lib/types/gameplay.d.ts.map +1 -0
  65. package/lib/types/gameplay.js +618 -0
  66. package/lib/types/ledger.d.ts +14 -0
  67. package/lib/types/ledger.d.ts.map +1 -1
  68. package/lib/types/ledger.js +30 -0
  69. package/lib/types/manifest-v2.d.ts +38 -1
  70. package/lib/types/manifest-v2.d.ts.map +1 -1
  71. package/lib/types/manifest-v2.js +182 -2
  72. package/lib/types/persist.d.ts +4 -4
  73. package/lib/types/persist.d.ts.map +1 -1
  74. package/lib/types/persist.js +41 -4
  75. package/lib/types/registry.d.ts +29 -0
  76. package/lib/types/registry.d.ts.map +1 -1
  77. package/lib/types/registry.js +170 -1
  78. package/lib/types/routes.d.ts.map +1 -1
  79. package/lib/types/routes.js +30 -3
  80. package/lib/types/service.d.ts +63 -11
  81. package/lib/types/service.d.ts.map +1 -1
  82. package/lib/types/service.js +208 -19
  83. package/lib/types/voice-pack.d.ts +6 -7
  84. package/lib/types/voice-pack.d.ts.map +1 -1
  85. package/lib/types/voice-pack.js +44 -47
  86. package/package.json +5 -2
  87. package/src/chatter.test.ts +68 -38
  88. package/src/chatter.ts +269 -316
  89. package/src/client/PetDockEntry.test.tsx +6 -0
  90. package/src/client/PetDockEntry.tsx +32 -1
  91. package/src/client/PetSprite.test.tsx +68 -21
  92. package/src/client/PetSprite.tsx +63 -45
  93. package/src/client/PluginSettingsCard.tsx +4 -2
  94. package/src/client/drag-stream.ts +35 -0
  95. package/src/client/gameplay-hud.test.tsx +338 -0
  96. package/src/client/gameplay-hud.tsx +403 -0
  97. package/src/client/index.test.tsx +10 -1
  98. package/src/client/index.ts +46 -11
  99. package/src/client/locales.ts +36 -0
  100. package/src/client/pet-store.ts +10 -1
  101. package/src/client/pet.module.css +192 -29
  102. package/src/client/renderers/Frames2dVisualMount.tsx +111 -0
  103. package/src/client/renderers/PetRendererSwitch.tsx +28 -1
  104. package/src/client/renderers/frames2d.test.ts +137 -0
  105. package/src/client/renderers/frames2d.ts +216 -0
  106. package/src/client/settings-card.module.css +5 -0
  107. package/src/defaults.ts +7 -0
  108. package/src/dsh-home.ts +9 -3
  109. package/src/event-projection.ts +57 -16
  110. package/src/gameplay.test.ts +239 -0
  111. package/src/gameplay.ts +692 -0
  112. package/src/ledger.test.ts +16 -0
  113. package/src/ledger.ts +31 -0
  114. package/src/manifest-v2.test.ts +130 -1
  115. package/src/manifest-v2.ts +181 -2
  116. package/src/persist.test.ts +3 -0
  117. package/src/persist.ts +43 -6
  118. package/src/registry.test.ts +168 -1
  119. package/src/registry.ts +199 -1
  120. package/src/routes.ts +28 -4
  121. package/src/service.ts +240 -29
  122. package/src/voice-pack.test.ts +48 -25
  123. package/src/voice-pack.ts +50 -47
@@ -0,0 +1,338 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * GameplayHud behavior tests: menu pages, touch taps through the bus, the
4
+ * idle director, the work/sleep loops and shop purchases — all with a mock
5
+ * verb API and fake timers so every roll is deterministic.
6
+ */
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8
+ import { act, cleanup, fireEvent, 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 PetDockEntry.test.tsx).
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
+ // Re-identity the root so useSyncExternalStore actually re-renders
25
+ // (the real engine store produces a fresh state per action).
26
+ value = { ...(value as Record<string, unknown>) }
27
+ for (const listener of listeners) listener()
28
+ }
29
+ }
30
+ return {
31
+ getSnapshot: () => value,
32
+ subscribe: (listener: () => void) => {
33
+ listeners.add(listener)
34
+ return () => { listeners.delete(listener) }
35
+ },
36
+ actions,
37
+ }
38
+ },
39
+ }),
40
+ }))
41
+ import { GameplayHud, type GameplayApi, type GameplayBus } from './gameplay-hud.tsx'
42
+ import { createPetStore, type PetStoreInstance } from './pet-store.ts'
43
+ import { createDragStream } from './drag-stream.ts'
44
+ import { t } from './locales.ts'
45
+ import type { PetDefinition } from '../registry.ts'
46
+ import type { PetGameplayStateView, PetGameplayVerbResult, PetStateView } from '../service.ts'
47
+
48
+ const VIEW: PetGameplayStateView = {
49
+ stats: { hunger: 100, mood: 100, energy: 100, affection: 100 },
50
+ mode: null,
51
+ }
52
+
53
+ function gameplayView(patch?: Partial<PetGameplayStateView>): PetGameplayStateView {
54
+ return { stats: { ...VIEW.stats }, mode: null, ...patch }
55
+ }
56
+
57
+ function petDefinition(): PetDefinition {
58
+ return {
59
+ id: 'miku',
60
+ displayName: 'Miku',
61
+ description: 'frames2d gameplay pet',
62
+ renderer: 'frames2d',
63
+ cell: { width: 100, height: 100 },
64
+ columns: 8,
65
+ rows: [6, 8, 8, 4, 5, 8, 6, 6, 6],
66
+ atlasRows: 9,
67
+ tracks: {} as PetDefinition['tracks'],
68
+ atlasUrl: '/pet/miku/thumb/idle/idle_1_200.webp',
69
+ manifestUrl: '/pet/miku/pet.json',
70
+ frames2d: {
71
+ tracks: {
72
+ idle: { frames: ['/pet/miku/thumb/idle/idle_1_200.webp'], durations: [200], loop: true },
73
+ happy: { frames: ['/pet/miku/thumb/happy/happy_1_200.webp'], durations: [200], loop: false, fallback: 'idle' },
74
+ work: { frames: ['/pet/miku/thumb/work/work_1_200.webp'], durations: [200], loop: true },
75
+ success: { frames: ['/pet/miku/thumb/success/success_1_200.webp'], durations: [200], loop: false, fallback: 'idle' },
76
+ fail: { frames: ['/pet/miku/thumb/fail/fail_1_200.webp'], durations: [200], loop: false, fallback: 'idle' },
77
+ sleep: { frames: ['/pet/miku/thumb/sleep/sleep_1_200.webp'], durations: [200], loop: true },
78
+ eat: { frames: ['/pet/miku/thumb/eat/eat_1_200.webp'], durations: [200], loop: false, fallback: 'idle' },
79
+ },
80
+ phases: { idle: 'idle', done: 'success', failed: 'fail' },
81
+ },
82
+ gameplay: {
83
+ idleDirector: { intervalMs: 5000, maxMiss: 2, idleWeight: 0, acts: [{ track: 'eat', weight: 1 }] },
84
+ stats: {
85
+ hunger: { max: 100 },
86
+ mood: { max: 100 },
87
+ energy: { max: 100 },
88
+ affection: { max: 500, initial: 100 },
89
+ },
90
+ hitBox: { x0: 0, y0: 0, x1: 1, y1: 1 },
91
+ touch: {
92
+ zones: [
93
+ { name: 'head', y0: 0, y1: 0.55, branches: [{ probability: 1, effects: [{ stat: 'affection', amount: 5 }], state: 'happy', stateMs: 3000, phrases: ['happy!'] }] },
94
+ ],
95
+ clickBoost: { stat: 'mood', min: 0, max: 3 },
96
+ },
97
+ work: {
98
+ state: 'work', successState: 'success', failState: 'fail', tickMs: 10_000,
99
+ resultMs: { success: 1300, fail: 1900 }, successProbability: 0.5,
100
+ success: { effects: [{ currency: 'treats', amount: 1 }] },
101
+ },
102
+ sleep: { state: 'sleep', restore: { stat: 'energy', amount: 4, intervalMs: 30_000 } },
103
+ shop: {
104
+ items: [
105
+ { id: 'food1', label: 'Bread', image: '/pet/miku/thumb/shop/food.webp', price: 2, currency: 'treats', effects: [{ stat: 'hunger', amount: 40 }] },
106
+ { id: 'lottery', label: 'Ticket', price: 3, currency: 'treats', lottery: { currency: 'treats', tiers: [{ probability: 1, prize: 5 }] } },
107
+ ],
108
+ },
109
+ },
110
+ }
111
+ }
112
+
113
+ function snapshot(view: PetGameplayStateView): PetStateView {
114
+ return {
115
+ animation: 'idle',
116
+ phase: 'idle',
117
+ sessionActive: false,
118
+ sessions: [],
119
+ affinity: { points: 0, rank: '幼鲸', rankEmoji: '*', pets: 0, feeds: 0, turns: 0, petCooldown: false, feedCooldown: false },
120
+ display: { visible: true, size: 160, right: 24, bottom: 20 },
121
+ pet: { id: 'miku', displayName: 'Miku', description: '' },
122
+ name: 'Miku',
123
+ treats: { stocked: 0, max: 5 },
124
+ gameplay: view,
125
+ }
126
+ }
127
+
128
+ interface Harness {
129
+ store: PetStoreInstance
130
+ bus: GameplayBus
131
+ api: GameplayApi & { touch: ReturnType<typeof vi.fn>; setMode: ReturnType<typeof vi.fn>; workTick: ReturnType<typeof vi.fn>; buy: ReturnType<typeof vi.fn> }
132
+ setTrack: ReturnType<typeof vi.fn>
133
+ drag: ReturnType<typeof createDragStream>
134
+ setView: (view: PetGameplayStateView) => void
135
+ }
136
+
137
+ function harness(view: PetGameplayStateView = gameplayView()): Harness {
138
+ const store = createPetStore().create()
139
+ store.actions.setSnapshot(snapshot(view))
140
+ const bus: GameplayBus = {}
141
+ const setTrack = vi.fn()
142
+ bus.setTrack = setTrack
143
+ const drag = createDragStream()
144
+ const ok = (patch?: Partial<PetGameplayVerbResult>): PetGameplayVerbResult => ({ ok: true, ...patch })
145
+ const api = {
146
+ touch: vi.fn(async () => ok({ hit: true, state: 'happy', stateMs: 3000, phrase: 'happy!', view: gameplayView({ stats: { ...VIEW.stats, affection: 105 } }) })),
147
+ setMode: vi.fn(async (mode: 'work' | 'sleep' | null) => ok({ view: gameplayView({ mode }) })),
148
+ workTick: vi.fn(async () => ok({ outcome: 'success' as const, view: gameplayView({ mode: 'work' }) })),
149
+ buy: vi.fn(async () => ok({ view: gameplayView() })),
150
+ }
151
+ const setView = (next: PetGameplayStateView): void => store.actions.setSnapshot(snapshot(next))
152
+ render(<GameplayHud definition={petDefinition()} store={store} api={api} bus={bus} drag={drag} t={t} />)
153
+ return { store, bus, api, setTrack, drag, setView }
154
+ }
155
+
156
+ describe('GameplayHud', () => {
157
+ beforeEach(() => {
158
+ vi.useFakeTimers()
159
+ })
160
+ afterEach(() => {
161
+ cleanup()
162
+ vi.useRealTimers()
163
+ })
164
+
165
+ it('opens the menu card through the bus openCard channel and shows the shop page', () => {
166
+ const h = harness()
167
+ expect(h.bus.openCard).toBeDefined()
168
+ act(() => { h.bus.openCard?.() })
169
+ expect(screen.getByText('饱食')).toBeDefined()
170
+ expect(screen.getByText('好感')).toBeDefined()
171
+ fireEvent.click(screen.getByText('商店'))
172
+ expect(screen.getByText('Bread')).toBeDefined()
173
+ fireEvent.click(screen.getByText('返回'))
174
+ expect(screen.getByText('打工')).toBeDefined()
175
+ // The wallet page is gone: no wallet action anywhere in the card.
176
+ expect(screen.queryByText('钱包')).toBeNull()
177
+ })
178
+
179
+
180
+ it('opens the card beside the pet, on the right when the space allows', () => {
181
+ // The parent float box (160x160) sits away from the viewport edges, so
182
+ // the right side fits the measured card width and the card anchors to
183
+ // the sprite's right edge, clamped to the sprite height.
184
+ const rect = (w: number, h: number, x: number, y: number): DOMRect => ({
185
+ top: y, right: x + w, bottom: y + h, left: x, width: w, height: h, x, y, toJSON: () => ({}) } as DOMRect)
186
+ vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280)
187
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
188
+ if (this.className.includes('gameplayHud')) return rect(0, 0, 300, 600)
189
+ if (this.className.includes('gameplayCard')) return rect(240, 150, 0, 600)
190
+ return rect(160, 160, 300, 600)
191
+ })
192
+ const h = harness()
193
+ act(() => { h.bus.openCard?.() })
194
+ const card = document.querySelector('[data-page]') as HTMLElement
195
+ // Right side: sprite width + 8px gap; vertically centered (up by half
196
+ // the sprite height); clamped to the sprite height (scrolls inside).
197
+ expect(card.style.transform).toContain('translate(168px, -80px)')
198
+ expect(card.style.maxHeight).toBe('160px')
199
+ })
200
+
201
+ it('flips the card to the left when the pet is parked near the right edge', () => {
202
+ const rect = (w: number, h: number, x: number, y: number): DOMRect => ({
203
+ top: y, right: x + w, bottom: y + h, left: x, width: w, height: h, x, y, toJSON: () => ({}) } as DOMRect)
204
+ vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280)
205
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
206
+ if (this.className.includes('gameplayHud')) return rect(0, 0, 1080, 600)
207
+ if (this.className.includes('gameplayCard')) return rect(240, 150, 0, 600)
208
+ return rect(160, 160, 1080, 600)
209
+ })
210
+ const h = harness()
211
+ act(() => { h.bus.openCard?.() })
212
+ const card = document.querySelector('[data-page]') as HTMLElement
213
+ // Only 40px remain to the right of the pet: the card opens to the left
214
+ // of the sprite (measured card width + 8px gap).
215
+ expect(card.style.transform).toContain('translate(-248px, -80px)')
216
+ })
217
+
218
+ it('toggles the card through openCard and closes with the close button', () => {
219
+ const h = harness()
220
+ act(() => { h.bus.openCard?.() })
221
+ expect(screen.getByText('饱食')).toBeDefined()
222
+ // A no-argument call toggles (the panel 玩法 button is a toggle).
223
+ act(() => { h.bus.openCard?.() })
224
+ expect(screen.queryByText('饱食')).toBeNull()
225
+ // An explicit boolean pins the state; the card close button still works.
226
+ act(() => { h.bus.openCard?.(true) })
227
+ expect(screen.getByText('饱食')).toBeDefined()
228
+ fireEvent.click(screen.getByLabelText('返回'))
229
+ expect(screen.queryByText('饱食')).toBeNull()
230
+ })
231
+
232
+ it('runs a touch tap through the bus: zone verb, track hold, phrase bubble', async () => {
233
+ const h = harness()
234
+ expect(h.bus.tap).toBeDefined()
235
+ await act(async () => {
236
+ h.bus.tap!(0.5, 0.3)
237
+ })
238
+ expect(h.api.touch).toHaveBeenCalledWith('head')
239
+ expect(h.setTrack).toHaveBeenCalledWith('happy')
240
+ expect(h.store.getSnapshot().feedback?.text).toBe('happy!')
241
+ expect(h.store.getSnapshot().snapshot?.gameplay?.stats.affection).toBe(105)
242
+ // The hold releases after stateMs.
243
+ await act(async () => {
244
+ vi.advanceTimersByTime(3100)
245
+ })
246
+ expect(h.setTrack).toHaveBeenCalledWith(undefined)
247
+ // A tap during the hold is the plain-click boost (no zone argument).
248
+ await act(async () => {
249
+ h.bus.tap!(0.5, 0.3)
250
+ h.bus.tap!(0.5, 0.3)
251
+ })
252
+ await act(async () => {
253
+ vi.advanceTimersByTime(100)
254
+ })
255
+ // Second tap landed inside the lock window? First tap re-locked; third is the boost.
256
+ const calls = h.api.touch.mock.calls.map(args => args[0])
257
+ expect(calls[0]).toBe('head')
258
+ })
259
+
260
+ it('ignores taps outside the hit box', async () => {
261
+ const def = petDefinition()
262
+ def.gameplay!.hitBox = { x0: 0.2, y0: 0.2, x1: 0.4, y1: 0.4 }
263
+ const store = createPetStore().create()
264
+ store.actions.setSnapshot(snapshot(gameplayView()))
265
+ const api = { touch: vi.fn(), setMode: vi.fn(), workTick: vi.fn(), buy: vi.fn() } as unknown as Harness['api']
266
+ const bus: GameplayBus = { setTrack: vi.fn() }
267
+ render(<GameplayHud definition={def} store={store} api={api} bus={bus} drag={createDragStream()} t={t} />)
268
+ await act(async () => {
269
+ bus.tap!(0.9, 0.9)
270
+ })
271
+ expect(api.touch).not.toHaveBeenCalled()
272
+ })
273
+
274
+ it('runs the idle director: weighted act rolls on the interval', async () => {
275
+ const h = harness()
276
+ await act(async () => {
277
+ vi.advanceTimersByTime(5000)
278
+ })
279
+ expect(h.setTrack).toHaveBeenCalledWith('eat')
280
+ // A non-idle phase suppresses the roll.
281
+ h.store.actions.setSnapshot(snapshot(gameplayView()))
282
+ h.setTrack.mockClear()
283
+ const busy = snapshot(gameplayView())
284
+ busy.phase = 'thinking'
285
+ await act(async () => {
286
+ h.store.actions.setSnapshot(busy)
287
+ })
288
+ await act(async () => {
289
+ vi.advanceTimersByTime(5000)
290
+ })
291
+ expect(h.setTrack).not.toHaveBeenCalled()
292
+ })
293
+
294
+ it('drives the work loop: work track, adjudicated ticks, result hold', async () => {
295
+ const h = harness(gameplayView({ mode: 'work' }))
296
+ expect(h.setTrack).toHaveBeenCalledWith('work')
297
+ await act(async () => {
298
+ vi.advanceTimersByTime(10_000)
299
+ })
300
+ expect(h.api.workTick).toHaveBeenCalled()
301
+ expect(h.setTrack).toHaveBeenCalledWith('success')
302
+ // Result hold (1300ms) then back to the work loop track.
303
+ await act(async () => {
304
+ vi.advanceTimersByTime(1400)
305
+ })
306
+ expect(h.setTrack).toHaveBeenLastCalledWith('work')
307
+ // The tick view lands in the store (treats ride the panel ledger, not the view).
308
+ expect(h.store.getSnapshot().snapshot?.gameplay?.mode).toBe('work')
309
+ })
310
+
311
+ it('holds the sleep track and wakes on drag', async () => {
312
+ const h = harness(gameplayView({ mode: 'sleep' }))
313
+ expect(h.setTrack).toHaveBeenCalledWith('sleep')
314
+ await act(async () => {
315
+ h.drag.push(true)
316
+ })
317
+ expect(h.api.setMode).toHaveBeenCalledWith(null)
318
+ expect(h.store.getSnapshot().snapshot?.gameplay?.mode).toBeNull()
319
+ })
320
+
321
+ it('buys shop items and floats the lottery prize', async () => {
322
+ const h = harness()
323
+ h.api.buy.mockResolvedValueOnce({ ok: false, error: 'insufficient-funds', view: gameplayView() })
324
+ h.api.buy.mockResolvedValueOnce({ ok: true, prize: { amount: 5, currency: 'treats' }, view: gameplayView() })
325
+ act(() => { h.bus.openCard?.() })
326
+ fireEvent.click(screen.getByText('商店'))
327
+ expect(screen.getByText('Bread')).toBeDefined()
328
+ await act(async () => {
329
+ fireEvent.click(screen.getByText('Bread'))
330
+ })
331
+ expect(h.api.buy).toHaveBeenCalledWith('food1')
332
+ expect(screen.getByText('小鱼干不足')).toBeDefined()
333
+ await act(async () => {
334
+ fireEvent.click(screen.getByText('Ticket'))
335
+ })
336
+ expect(screen.getByText('中奖 +5 小鱼干')).toBeDefined()
337
+ })
338
+ })