@linxin666/dsh-pet 0.2.3 → 0.2.4

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 (70) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +64 -1
  3. package/README.zh.md +64 -1
  4. package/assets/decorations/whale/decoration.json +20 -0
  5. package/assets/decorations/whale/whale-frames.png +0 -0
  6. package/contracts/pet-manifest-v2.schema.json +281 -0
  7. package/contracts/status-decoration-v1.schema.json +144 -0
  8. package/contracts/voice-pack-v1.schema.json +234 -0
  9. package/lib/client.js +144 -18
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +979 -70
  12. package/lib/types/chatter.d.ts +61 -3
  13. package/lib/types/chatter.d.ts.map +1 -1
  14. package/lib/types/chatter.js +71 -12
  15. package/lib/types/client/PetSettingsCard.d.ts +4 -0
  16. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  17. package/lib/types/client/PetSettingsCard.js +3 -1
  18. package/lib/types/client/PetSprite.d.ts.map +1 -1
  19. package/lib/types/client/PetSprite.js +103 -4
  20. package/lib/types/client/locales.d.ts +4 -0
  21. package/lib/types/client/locales.d.ts.map +1 -1
  22. package/lib/types/client/locales.js +4 -0
  23. package/lib/types/client/renderers/live2d.d.ts.map +1 -1
  24. package/lib/types/client/renderers/live2d.js +13 -1
  25. package/lib/types/client/settings-form.d.ts.map +1 -1
  26. package/lib/types/client/settings-form.js +9 -6
  27. package/lib/types/contracts/status-decoration.d.ts +85 -0
  28. package/lib/types/contracts/status-decoration.d.ts.map +1 -0
  29. package/lib/types/contracts/status-decoration.js +21 -0
  30. package/lib/types/decoration.d.ts +39 -0
  31. package/lib/types/decoration.d.ts.map +1 -0
  32. package/lib/types/decoration.js +210 -0
  33. package/lib/types/event-projection.d.ts +8 -3
  34. package/lib/types/event-projection.d.ts.map +1 -1
  35. package/lib/types/event-projection.js +9 -4
  36. package/lib/types/index.d.ts +2 -0
  37. package/lib/types/index.d.ts.map +1 -1
  38. package/lib/types/index.js +2 -0
  39. package/lib/types/registry.d.ts +55 -2
  40. package/lib/types/registry.d.ts.map +1 -1
  41. package/lib/types/registry.js +198 -16
  42. package/lib/types/routes.d.ts.map +1 -1
  43. package/lib/types/routes.js +131 -3
  44. package/lib/types/service.d.ts +33 -0
  45. package/lib/types/service.d.ts.map +1 -1
  46. package/lib/types/service.js +42 -3
  47. package/lib/types/voice-pack.d.ts +98 -0
  48. package/lib/types/voice-pack.d.ts.map +1 -0
  49. package/lib/types/voice-pack.js +384 -0
  50. package/package.json +11 -10
  51. package/src/chatter.test.ts +89 -2
  52. package/src/chatter.ts +120 -14
  53. package/src/client/PetSettingsCard.tsx +18 -0
  54. package/src/client/PetSprite.test.tsx +254 -12
  55. package/src/client/PetSprite.tsx +142 -25
  56. package/src/client/locales.ts +4 -0
  57. package/src/client/renderers/live2d.test.ts +23 -2
  58. package/src/client/renderers/live2d.ts +14 -1
  59. package/src/client/settings-form.ts +8 -6
  60. package/src/contracts/status-decoration.ts +78 -0
  61. package/src/decoration.test.ts +178 -0
  62. package/src/decoration.ts +220 -0
  63. package/src/event-projection.ts +10 -5
  64. package/src/index.ts +2 -0
  65. package/src/registry.test.ts +223 -0
  66. package/src/registry.ts +235 -15
  67. package/src/routes.ts +128 -3
  68. package/src/service.ts +59 -3
  69. package/src/voice-pack.test.ts +216 -0
  70. package/src/voice-pack.ts +413 -0
package/src/chatter.ts CHANGED
@@ -17,6 +17,11 @@
17
17
  * plugin has always shown, so existing installs keep their wording until the
18
18
  * scene cycles. No emoji anywhere (repository rule); ~ is the whale-girl's
19
19
  * signature.
20
+ *
21
+ * Since pet-center M4 (issue #677) every pool is overridable through a
22
+ * {@link VoicePoolsProvider}: the built-in pools are the fallback layer, and
23
+ * voice packs (per-pet voice.json / the global .voice.json) layer their
24
+ * pools on top at draw time.
20
25
  * @module @linxin666/dsh-pet/chatter
21
26
  */
22
27
 
@@ -193,6 +198,18 @@ export type ToolCategory =
193
198
  | 'ask'
194
199
  | 'generic'
195
200
 
201
+ /** Every status scene key, in declaration order (voice-pack key allow-list). */
202
+ export const STATUS_SCENES: readonly StatusScene[] = [
203
+ 'prepare', 'waiting', 'thinking', 'review', 'toolResult', 'done',
204
+ 'failed', 'toolFailed', 'maxTokens', 'interrupted', 'blocked',
205
+ ]
206
+
207
+ /** Every tool-family key, in declaration order (voice-pack key allow-list). */
208
+ export const TOOL_CATEGORIES: readonly ToolCategory[] = [
209
+ 'read', 'write', 'edit', 'shell', 'grep', 'find', 'ls', 'webSearch',
210
+ 'webFetch', 'mcp', 'memory', 'subagent', 'todo', 'browser', 'git', 'ask', 'generic',
211
+ ]
212
+
196
213
  /** Map a raw tool name onto its copy family (working-activity style regexes). */
197
214
  export function toolCategory(toolName: string): ToolCategory {
198
215
  const name = toolName.toLowerCase()
@@ -433,12 +450,19 @@ export function toolArgHint(toolName: string, argumentsJson: string): string | u
433
450
  * stretch keeps changing its wording.
434
451
  */
435
452
  export class StatusVoice {
453
+ private readonly pools: VoicePoolsProvider
454
+ private readonly rotateMs: number
436
455
  private readonly counters = new Map<string, number>()
437
456
  private lastScene = ''
438
457
  private lastLine = ''
439
458
  private lastLineAt = Number.NEGATIVE_INFINITY
440
459
 
441
- constructor(private readonly rotateMs: number = STATUS_ROTATE_MS) {}
460
+ constructor(pools: VoicePoolsProvider = () => BUILTIN_VOICE_PACK, rotateMs: number = STATUS_ROTATE_MS) {
461
+ // Plain property assignment, not parameter properties: this module is
462
+ // imported by scripts/ under node's strip-only mode (pet-center M4).
463
+ this.pools = pools
464
+ this.rotateMs = rotateMs
465
+ }
442
466
 
443
467
  /** Draw the next line of one pool, advancing its round-robin cursor. */
444
468
  private draw(poolKey: string, pool: readonly string[]): string {
@@ -456,24 +480,38 @@ export class StatusVoice {
456
480
  return this.lastLine
457
481
  }
458
482
 
483
+ /**
484
+ * A scene's effective pool: the voice-pack override when it carries lines,
485
+ * else the built-in pool. Empty overrides fall back rather than blank the
486
+ * bubble — a scene line always renders.
487
+ */
488
+ private scenePool(scene: StatusScene): readonly string[] {
489
+ const override = this.pools().status?.[scene]
490
+ return override !== undefined && override.length > 0 ? override : STATUS_POOLS[scene]
491
+ }
492
+
459
493
  /** Status line for a phase scene. */
460
494
  scene(scene: StatusScene, nowMs: number): string {
461
- return this.voice('scene:' + scene, 'pool:' + scene, STATUS_POOLS[scene], nowMs)
495
+ return this.voice('scene:' + scene, 'pool:' + scene, this.scenePool(scene), nowMs)
462
496
  }
463
497
 
464
498
  /** Status line for a tool call, with the real-argument hint when known. */
465
499
  tool(toolName: string, displayName: string, hint: string | undefined, nowMs: number): string {
466
500
  const category = toolCategory(toolName)
467
- const line = this.voice('tool:' + category, 'tool:' + category, TOOL_POOLS[category], nowMs)
501
+ const override = this.pools().tools?.[category]
502
+ const pool = override !== undefined && override.length > 0 ? override : TOOL_POOLS[category]
503
+ const line = this.voice('tool:' + category, 'tool:' + category, pool, nowMs)
468
504
  return line
469
- .replace('{tool}', displayName)
470
- .replace('{hint}', hint ?? displayName)
505
+ .replaceAll('{tool}', displayName)
506
+ .replaceAll('{hint}', hint ?? displayName)
471
507
  }
472
508
 
473
509
  /** Status line while sibling tools still run (always reflects the count). */
474
510
  toolRemaining(count: number, nowMs: number): string {
475
- return this.voice('toolRemaining', 'toolRemaining', TOOL_REMAINING_POOL, nowMs)
476
- .replace('{n}', String(count))
511
+ const override = this.pools().toolRemaining
512
+ const pool = override !== undefined && override.length > 0 ? override : TOOL_REMAINING_POOL
513
+ return this.voice('toolRemaining', 'toolRemaining', pool, nowMs)
514
+ .replaceAll('{n}', String(count))
477
515
  }
478
516
  }
479
517
 
@@ -734,24 +772,89 @@ export const WHISPER_RULES: readonly WhisperRule[] = [
734
772
  },
735
773
  ]
736
774
 
775
+ /**
776
+ * Voice-pack overrides (pet-center M4, issue #677): the content a voice
777
+ * pack can replace, one pool at a time. Every field is optional — missing
778
+ * keys inherit the built-in pools. Resolution happens at draw time through
779
+ * a provider function, so swapping pets (or editing the global file) re-
780
+ * voices live engines without rebuilding them.
781
+ *
782
+ * Override semantics:
783
+ * - status/tools/toolRemaining: a non-empty override replaces the built-in
784
+ * pool for that key; an empty override falls back to the built-in pool
785
+ * (a scene line always renders, so it can never be blanked).
786
+ * - whispers.generic / whispers.rules: the override REPLACES the built-in
787
+ * section; an empty array mutes that channel (ambient or keyword).
788
+ */
789
+ export interface VoicePackOverrides {
790
+ /** Status copy pools by scene; each key replaces that scene's pool. */
791
+ status?: Partial<Record<StatusScene, readonly string[]>>
792
+ /** Tool copy pools by family; each key replaces that family's pool. */
793
+ tools?: Partial<Record<ToolCategory, readonly string[]>>
794
+ /** The parallel-tools count line pool ({n} interpolates the count). */
795
+ toolRemaining?: readonly string[]
796
+ /** Murmur pools; each section replaces the built-in one as a whole. */
797
+ whispers?: {
798
+ /** Ambient inner-whisper pool (empty mutes ambient whispers). */
799
+ generic?: readonly string[]
800
+ /** Ordered keyword rules (empty disables keyword-triggered whispers). */
801
+ rules?: readonly WhisperRule[]
802
+ }
803
+ }
804
+
805
+ /** Read the current effective voice-pack overrides (draw-time resolution). */
806
+ export type VoicePoolsProvider = () => VoicePackOverrides
807
+
808
+ /** The built-in voice pack: the plugin's default copy, unchanged since v1. */
809
+ export const BUILTIN_VOICE_PACK: VoicePackOverrides = {
810
+ status: STATUS_POOLS,
811
+ tools: TOOL_POOLS,
812
+ toolRemaining: TOOL_REMAINING_POOL,
813
+ whispers: { generic: WHISPER_GENERIC_POOL, rules: WHISPER_RULES },
814
+ }
815
+
737
816
  /**
738
817
  * The murmur engine (碎碎念): watches the model's own output and lets the pet
739
818
  * whisper its inner voice. Two ways to earn a whisper:
740
819
  * - a keyword rule matches the fresh chunk text (themed whisper);
741
820
  * - enough output volume flowed by without one (ambient whisper).
742
821
  * A cooldown keeps whispers occasional; all picks are round-robin so tests
743
- * reproduce exact lines.
822
+ * reproduce exact lines. The voice-pack provider (pet-center M4) swaps the
823
+ * pools at draw time, so a pet switch re-voices live engines in place.
744
824
  */
745
825
  export class WhisperEngine {
826
+ private readonly pools: VoicePoolsProvider
827
+ private readonly cooldownMs: number
828
+ private readonly charBudget: number
746
829
  private readonly counters = new Map<number, number>()
747
830
  private genericCursor = 0
748
831
  private lastWhisperAt = Number.NEGATIVE_INFINITY
749
832
  private charsSinceWhisper = 0
750
833
 
751
834
  constructor(
752
- private readonly cooldownMs: number = WHISPER_COOLDOWN_MS,
753
- private readonly charBudget: number = WHISPER_CHAR_BUDGET,
754
- ) {}
835
+ pools: VoicePoolsProvider = () => BUILTIN_VOICE_PACK,
836
+ cooldownMs: number = WHISPER_COOLDOWN_MS,
837
+ charBudget: number = WHISPER_CHAR_BUDGET,
838
+ ) {
839
+ this.pools = pools
840
+ this.cooldownMs = cooldownMs
841
+ this.charBudget = charBudget
842
+ }
843
+
844
+ /**
845
+ * Effective keyword rules: an override replaces the built-in rules as a
846
+ * whole; an explicit empty array disables keyword-triggered whispers.
847
+ */
848
+ private rules(): readonly WhisperRule[] {
849
+ const override = this.pools().whispers?.rules
850
+ return override === undefined ? WHISPER_RULES : override
851
+ }
852
+
853
+ /** Effective ambient pool (an explicit empty array mutes ambient whispers). */
854
+ private generic(): readonly string[] {
855
+ const override = this.pools().whispers?.generic
856
+ return override === undefined ? WHISPER_GENERIC_POOL : override
857
+ }
755
858
 
756
859
  /**
757
860
  * Feed one model-output chunk (reasoning or text). Returns the whisper to
@@ -765,8 +868,9 @@ export class WhisperEngine {
765
868
  return undefined
766
869
  }
767
870
  const haystack = text.toLowerCase()
768
- for (let ruleIndex = 0; ruleIndex < WHISPER_RULES.length; ruleIndex += 1) {
769
- const rule = WHISPER_RULES[ruleIndex]!
871
+ const rules = this.rules()
872
+ for (let ruleIndex = 0; ruleIndex < rules.length; ruleIndex += 1) {
873
+ const rule = rules[ruleIndex]!
770
874
  if (!rule.keywords.some(keyword => haystack.includes(keyword))) continue
771
875
  const index = (this.counters.get(ruleIndex) ?? 0) % rule.pool.length
772
876
  this.counters.set(ruleIndex, index + 1)
@@ -774,7 +878,9 @@ export class WhisperEngine {
774
878
  }
775
879
  this.charsSinceWhisper += text.length
776
880
  if (this.charsSinceWhisper < this.charBudget) return undefined
777
- const line = WHISPER_GENERIC_POOL[this.genericCursor % WHISPER_GENERIC_POOL.length]!
881
+ const generic = this.generic()
882
+ if (generic.length === 0) return undefined
883
+ const line = generic[this.genericCursor % generic.length]!
778
884
  this.genericCursor += 1
779
885
  return this.speak(line, nowMs)
780
886
  }
@@ -30,6 +30,8 @@ export interface PetSettings {
30
30
  bottom?: number
31
31
  /** Selected pet id (a registry entry). */
32
32
  petId?: string
33
+ /** Status-decoration master switch (pet-center M5, #567). */
34
+ decorationEnabled?: boolean
33
35
  }
34
36
 
35
37
  /** What the pet settings card renders. */
@@ -46,6 +48,8 @@ export interface PetSettingsCardState extends CardShell {
46
48
  bottom: CardFieldState
47
49
  /** Selected pet. */
48
50
  petId: CardFieldState
51
+ /** Status-decoration master switch. */
52
+ decorationEnabled: CardFieldState
49
53
  /** Pet choices (registry ids + display names), loaded from the host. */
50
54
  petChoices: readonly { value: string; label: string }[]
51
55
  /** Registry diagnostics (v1 migration hints, invalid entries), host-served. */
@@ -104,6 +108,7 @@ export class PetSettingsCardController {
104
108
  constructor(scope: SettingsScope<PetSettings>) {
105
109
  this.form = new CardForm(scope, [
106
110
  booleanField('enabled'),
111
+ booleanField('decorationEnabled'),
107
112
  booleanField('visible'),
108
113
  numberField('size'),
109
114
  numberField('right'),
@@ -146,6 +151,7 @@ export class PetSettingsCardController {
146
151
  return {
147
152
  ...this.form.shell(),
148
153
  enabled: this.form.field('enabled'),
154
+ decorationEnabled: this.form.field('decorationEnabled'),
149
155
  visible: this.form.field('visible'),
150
156
  size: this.form.field('size'),
151
157
  right: this.form.field('right'),
@@ -215,6 +221,18 @@ export function PetSettingsCard(props: PetSettingsCardProps) {
215
221
  onEdit={(text) => { props.edit('enabled', text) }}
216
222
  onReset={() => { props.resetField('enabled') }}
217
223
  />
224
+ <BooleanField
225
+ id="settings-pet-decoration"
226
+ label={t('settings.decoration')}
227
+ hint={t('settings.decorationHint')}
228
+ inheritLabel={t('settings.inherit')}
229
+ onLabel={t('settings.on')}
230
+ offLabel={t('settings.off')}
231
+ {...fieldProps}
232
+ {...state.decorationEnabled}
233
+ onEdit={(text) => { props.edit('decorationEnabled', text) }}
234
+ onReset={() => { props.resetField('decorationEnabled') }}
235
+ />
218
236
  <ChoiceField
219
237
  id="settings-pet-pet"
220
238
  label={t('settings.pet')}
@@ -11,6 +11,7 @@ import { t } from './locales.ts'
11
11
  import type { PetStateView } from '../service.ts'
12
12
  import type { PetDefinition, PetTrackDef } from '../registry.ts'
13
13
  import type { PetAnimation } from '../state.ts'
14
+ import type { DecorationView } from '../contracts/status-decoration.ts'
14
15
 
15
16
  /** A minimal pet definition (geometry + tracks) as served by the host. */
16
17
  function petDefinition(): PetDefinition {
@@ -91,14 +92,9 @@ afterEach(() => {
91
92
  vi.restoreAllMocks()
92
93
  })
93
94
 
94
- /** Render the pet with mocked callbacks; returns the rename and open spys. */
95
- function renderPet(overrides: Partial<PetSpriteProps> = {}): {
96
- onRename: ReturnType<typeof vi.fn>
97
- onOpenSession: ReturnType<typeof vi.fn>
98
- } {
99
- const onRename = vi.fn()
100
- const onOpenSession = vi.fn()
101
- const props: PetSpriteProps = {
95
+ /** Build the mocked props for one render. */
96
+ function petProps(overrides: Partial<PetSpriteProps> = {}): PetSpriteProps {
97
+ return {
102
98
  snapshot,
103
99
  definition: petDefinition(),
104
100
  display: snapshot.display,
@@ -107,14 +103,24 @@ function renderPet(overrides: Partial<PetSpriteProps> = {}): {
107
103
  onFeed: vi.fn(),
108
104
  onHide: vi.fn(),
109
105
  onDragEnd: vi.fn(),
110
- onRename,
111
- onOpenSession,
106
+ onRename: vi.fn(),
107
+ onOpenSession: vi.fn(),
112
108
  onFeedbackDone: vi.fn(),
113
109
  t,
114
110
  ...overrides,
115
111
  }
116
- render(<PetSprite {...props} />)
117
- return { onRename, onOpenSession }
112
+ }
113
+
114
+ /** Render the pet with mocked callbacks; returns the rename/open spys + the RTL result. */
115
+ function renderPet(overrides: Partial<PetSpriteProps> = {}): {
116
+ onRename: ReturnType<typeof vi.fn>
117
+ onOpenSession: ReturnType<typeof vi.fn>
118
+ result: ReturnType<typeof render>
119
+ } {
120
+ const onRename = vi.fn()
121
+ const onOpenSession = vi.fn()
122
+ const result = render(<PetSprite {...petProps({ onRename, onOpenSession, ...overrides })} />)
123
+ return { onRename, onOpenSession, result }
118
124
  }
119
125
 
120
126
  /** Hover the sprite to open the panel, then click the rename button. */
@@ -555,3 +561,239 @@ describe('PetSprite definition-driven render', () => {
555
561
  expect(sprite.style.backgroundPosition).toBe('0px -960px')
556
562
  })
557
563
  })
564
+
565
+ describe('PetSprite panel chrome from the voice pack (pet-center M4)', () => {
566
+ const voicedDefinition = (): PetDefinition => ({
567
+ ...petDefinition(),
568
+ panel: {
569
+ labels: { feed: '投喂', rename: '起名字', hide: '藏起来', confirm: '好的' },
570
+ stats: { rank: '好感 {rank}', treats: '鱼干 {n}', points: '{points} 分' },
571
+ },
572
+ })
573
+
574
+ it('renders pack labels and stat formats, falling back per slot', () => {
575
+ renderPet({ definition: voicedDefinition() })
576
+ fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
577
+ expect(screen.getByText('投喂')).toBeDefined()
578
+ expect(screen.getByText('起名字')).toBeDefined()
579
+ expect(screen.getByText('藏起来')).toBeDefined()
580
+ expect(screen.getByText('好感 幼鲸')).toBeDefined()
581
+ expect(screen.getByText('鱼干 3')).toBeDefined()
582
+ expect(screen.getByText('0 分')).toBeDefined()
583
+ })
584
+
585
+ it('uses the pack confirm label inside the rename row', () => {
586
+ renderPet({ definition: voicedDefinition() })
587
+ fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
588
+ fireEvent.click(screen.getByText('起名字'))
589
+ expect(screen.getByText('好的')).toBeDefined()
590
+ })
591
+
592
+ it('hides actions the pack omits', () => {
593
+ renderPet({
594
+ definition: {
595
+ ...petDefinition(),
596
+ panel: { labels: { feed: '投喂' }, actions: ['feed'] },
597
+ },
598
+ })
599
+ fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
600
+ expect(screen.getByText('投喂')).toBeDefined()
601
+ expect(screen.queryByText('改名')).toBeNull()
602
+ expect(screen.queryByText('隐藏')).toBeNull()
603
+ })
604
+
605
+ it('renders no action buttons when the pack hides them all', () => {
606
+ renderPet({ definition: { ...petDefinition(), panel: { actions: [] } } })
607
+ fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
608
+ expect(screen.queryByText('喂食')).toBeNull()
609
+ expect(screen.queryByText('改名')).toBeNull()
610
+ expect(screen.queryByText('隐藏')).toBeNull()
611
+ // The stat rows keep rendering.
612
+ expect(screen.getByText('亲密度 幼鲸')).toBeDefined()
613
+ expect(screen.getByText('小鱼干 ×3')).toBeDefined()
614
+ })
615
+
616
+ it('keeps the i18n copy when the pet carries no panel', () => {
617
+ renderPet()
618
+ fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
619
+ expect(screen.getByText('喂食')).toBeDefined()
620
+ expect(screen.getByText('改名')).toBeDefined()
621
+ expect(screen.getByText('隐藏')).toBeDefined()
622
+ expect(screen.getByText('亲密度 幼鲸')).toBeDefined()
623
+ })
624
+
625
+ it('substitutes cross-slot placeholders in pack stat formats', () => {
626
+ renderPet({ definition: { ...petDefinition(), panel: { stats: { treats: '鱼干 {n}({points} 分,{rank})' } } } })
627
+ fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
628
+ // The host whitelists {rank}/{n}/{points} in every stat slot, so a pack
629
+ // format may reference any of them; all three live values substitute.
630
+ expect(screen.getByText('鱼干 3(0 分,幼鲸)')).toBeDefined()
631
+ })
632
+ })
633
+ describe('PetSprite status decoration (pet-center M5, #567)', () => {
634
+ const decoration: DecorationView = {
635
+ apiVersion: 'x-org.linxin666.pet-center/status-decoration-v1',
636
+ id: 'whale',
637
+ assetBase: '/api/pet/decoration/whale',
638
+ entryUrl: '/api/pet/decoration/whale/whale-frames.png',
639
+ cell: { width: 64, height: 48 },
640
+ columns: 4,
641
+ durations: [160, 160, 160, 160],
642
+ loop: true,
643
+ phases: {
644
+ idle: 'hide',
645
+ waiting: { from: 0, to: 1 },
646
+ thinking: { from: 0, to: 3 },
647
+ done: { from: 2, to: 3 },
648
+ },
649
+ }
650
+
651
+ const ornament = (): HTMLElement | null => document.body.querySelector('[data-dsh-pet-decoration="whale"]')
652
+
653
+ it('renders an aria-hidden ornament inside the status bubble for a bound phase', () => {
654
+ renderPet({ snapshot: { ...snapshot, bubble: '正在思考', phase: 'thinking', decoration } })
655
+ const el = ornament()
656
+ expect(el).not.toBeNull()
657
+ expect(el!.getAttribute('aria-hidden')).toBe('true')
658
+ expect(el!.style.backgroundImage).toContain('whale-frames.png')
659
+ // The bubble keeps its semantics beside the ornament.
660
+ const bubble = document.body.querySelector('[role="status"][aria-live="polite"]')
661
+ expect(bubble).not.toBeNull()
662
+ expect(bubble!.textContent).toContain('正在思考')
663
+ })
664
+
665
+ it('hides the ornament for phases bound to hide and for the idle default', () => {
666
+ renderPet({ snapshot: { ...snapshot, bubble: '等待', phase: 'idle', decoration } })
667
+ expect(ornament()).toBeNull()
668
+ })
669
+
670
+ it('holds the segment first frame under prefers-reduced-motion', () => {
671
+ renderPet({ snapshot: { ...snapshot, bubble: '完成', phase: 'done', decoration } })
672
+ const el = ornament()
673
+ expect(el).not.toBeNull()
674
+ // The harness matchMedia mock reports reduced motion, so the ornament
675
+ // rests on the segment's first frame (column 2 of a 24px-wide frame).
676
+ expect(el!.style.backgroundPosition).toBe('-48px 0px')
677
+ })
678
+
679
+ it('yields the bubble to the whisper (voice moment hides the ornament)', () => {
680
+ renderPet({ snapshot: { ...snapshot, phase: 'thinking', whisper: '冲了冲了', decoration } })
681
+ expect(ornament()).toBeNull()
682
+ expect(document.body.textContent).toContain('冲了冲了')
683
+ })
684
+
685
+ it('renders no ornament when the host serves no decoration', () => {
686
+ renderPet({ snapshot: { ...snapshot, bubble: '正在思考', phase: 'thinking' } })
687
+ expect(ornament()).toBeNull()
688
+ })
689
+
690
+ it('advances the ornament frames on the rAF loop and wraps when looping', () => {
691
+ vi.spyOn(window, 'matchMedia').mockReturnValue({
692
+ matches: false,
693
+ media: '(prefers-reduced-motion: reduce)',
694
+ onchange: null,
695
+ addEventListener: () => {},
696
+ removeEventListener: () => {},
697
+ addListener: () => {},
698
+ removeListener: () => {},
699
+ dispatchEvent: () => false,
700
+ })
701
+ vi.spyOn(performance, 'now').mockReturnValue(0)
702
+ const frames: FrameRequestCallback[] = []
703
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
704
+ frames.push(callback)
705
+ return frames.length
706
+ })
707
+ vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
708
+ renderPet({ snapshot: { ...snapshot, bubble: '正在思考', phase: 'thinking', decoration } })
709
+ const el = ornament()!
710
+ // Both the sprite loop and the ornament loop schedule frames; step every
711
+ // pending callback together (the sprite's idle track never moves).
712
+ const step = (ts: number): void => { for (const callback of frames.splice(0)) callback(ts) }
713
+ // frameWidth = round(64 * 18 / 48) = 24 px; thinking binds frames 0..3.
714
+ expect(el.style.backgroundPosition).toBe('0px 0px')
715
+ act(() => { step(161) })
716
+ expect(el.style.backgroundPosition).toBe('-24px 0px')
717
+ act(() => { step(322) })
718
+ expect(el.style.backgroundPosition).toBe('-48px 0px')
719
+ act(() => { step(483) })
720
+ expect(el.style.backgroundPosition).toBe('-72px 0px')
721
+ act(() => { step(644) })
722
+ // The looping segment wraps back to its first frame.
723
+ expect(el.style.backgroundPosition).toBe('0px 0px')
724
+ })
725
+
726
+ it('holds the segment last frame when the loop is off and stops scheduling', () => {
727
+ vi.spyOn(window, 'matchMedia').mockReturnValue({
728
+ matches: false,
729
+ media: '(prefers-reduced-motion: reduce)',
730
+ onchange: null,
731
+ addEventListener: () => {},
732
+ removeEventListener: () => {},
733
+ addListener: () => {},
734
+ removeListener: () => {},
735
+ dispatchEvent: () => false,
736
+ })
737
+ vi.spyOn(performance, 'now').mockReturnValue(0)
738
+ const frames: FrameRequestCallback[] = []
739
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
740
+ frames.push(callback)
741
+ return frames.length
742
+ })
743
+ vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
744
+ renderPet({ snapshot: { ...snapshot, bubble: '完成', phase: 'done', decoration: { ...decoration, loop: false } } })
745
+ const el = ornament()!
746
+ const step = (ts: number): void => { for (const callback of frames.splice(0)) callback(ts) }
747
+ // done binds frames 2..3; the segment starts on frame 2.
748
+ expect(el.style.backgroundPosition).toBe('-48px 0px')
749
+ act(() => { step(161) })
750
+ expect(el.style.backgroundPosition).toBe('-72px 0px')
751
+ // The ornament stopped scheduling (only the sprite loop remains pending).
752
+ expect(frames).toHaveLength(1)
753
+ act(() => { step(161) })
754
+ // The last frame holds.
755
+ expect(el.style.backgroundPosition).toBe('-72px 0px')
756
+ })
757
+
758
+ it('does not restart the frame loop when an equal-content decoration re-renders', () => {
759
+ vi.spyOn(window, 'matchMedia').mockReturnValue({
760
+ matches: false,
761
+ media: '(prefers-reduced-motion: reduce)',
762
+ onchange: null,
763
+ addEventListener: () => {},
764
+ removeEventListener: () => {},
765
+ addListener: () => {},
766
+ removeListener: () => {},
767
+ dispatchEvent: () => false,
768
+ })
769
+ vi.spyOn(performance, 'now').mockReturnValue(0)
770
+ const frames: FrameRequestCallback[] = []
771
+ const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
772
+ frames.push(callback)
773
+ return frames.length
774
+ })
775
+ const cancelSpy = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
776
+ // The definition comes from '/api/pet/pets', fetched once — a state
777
+ // poll never replaces it, so both renders share one definition object.
778
+ const definition = petDefinition()
779
+ const { result } = renderPet({ definition, snapshot: { ...snapshot, bubble: '正在思考', phase: 'thinking', decoration } })
780
+ const step = (ts: number): void => { for (const callback of frames.splice(0)) callback(ts) }
781
+ act(() => { step(161) })
782
+ expect(ornament()!.style.backgroundPosition).toBe('-24px 0px')
783
+ const schedulesBefore = rafSpy.mock.calls.length
784
+ // The 2 s poll delivers a fresh JSON round-trip: identical content, new
785
+ // object identities everywhere. The loop must not cancel/restart.
786
+ const repolled: DecorationView = {
787
+ ...decoration,
788
+ cell: { ...decoration.cell },
789
+ durations: [...decoration.durations],
790
+ phases: { ...decoration.phases },
791
+ }
792
+ result.rerender(<PetSprite {...petProps({ definition, snapshot: { ...snapshot, bubble: '正在思考', phase: 'thinking', decoration: repolled } })} />)
793
+ act(() => { step(322) })
794
+ expect(ornament()!.style.backgroundPosition).toBe('-48px 0px')
795
+ expect(cancelSpy).not.toHaveBeenCalled()
796
+ // One reschedule per loop tick; no effect restart added new schedules.
797
+ expect(rafSpy.mock.calls.length).toBe(schedulesBefore + 2)
798
+ })
799
+ })