@linxin666/dsh-pet 0.2.4 → 0.2.6

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 (45) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +1 -1
  3. package/README.zh.md +1 -1
  4. package/lib/client.js +110 -28
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +164 -34
  7. package/lib/types/access.d.ts +16 -0
  8. package/lib/types/access.d.ts.map +1 -0
  9. package/lib/types/access.js +20 -0
  10. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  11. package/lib/types/client/PetSettingsCard.js +8 -2
  12. package/lib/types/client/PetSprite.d.ts.map +1 -1
  13. package/lib/types/client/PetSprite.js +10 -2
  14. package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -1
  15. package/lib/types/client/renderers/live2d/Live2dVisualMount.js +1 -0
  16. package/lib/types/client/renderers/live2d/runtime.d.ts +13 -1
  17. package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -1
  18. package/lib/types/client/renderers/live2d.d.ts.map +1 -1
  19. package/lib/types/client/renderers/live2d.js +120 -24
  20. package/lib/types/image-dimensions.d.ts +26 -0
  21. package/lib/types/image-dimensions.d.ts.map +1 -0
  22. package/lib/types/image-dimensions.js +77 -0
  23. package/lib/types/index.js +1 -1
  24. package/lib/types/registry.d.ts.map +1 -1
  25. package/lib/types/registry.js +43 -1
  26. package/lib/types/routes.d.ts +6 -1
  27. package/lib/types/routes.d.ts.map +1 -1
  28. package/lib/types/routes.js +36 -33
  29. package/package.json +1 -1
  30. package/src/access.ts +40 -0
  31. package/src/client/PetSettingsCard.tsx +8 -2
  32. package/src/client/PetSprite.test.tsx +71 -0
  33. package/src/client/PetSprite.tsx +10 -2
  34. package/src/client/renderers/live2d/Live2dVisualMount.test.tsx +72 -0
  35. package/src/client/renderers/live2d/Live2dVisualMount.tsx +1 -0
  36. package/src/client/renderers/live2d/runtime.ts +8 -2
  37. package/src/client/renderers/live2d.test.ts +211 -8
  38. package/src/client/renderers/live2d.ts +129 -29
  39. package/src/client/settings-card.module.css +2 -0
  40. package/src/image-dimensions.test.ts +85 -0
  41. package/src/image-dimensions.ts +76 -0
  42. package/src/index.ts +1 -1
  43. package/src/registry.test.ts +58 -5
  44. package/src/registry.ts +40 -1
  45. package/src/routes.ts +38 -34
package/src/access.ts ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Pet trust fence: loopback (the desktop) always passes; a live paired-device
3
+ * cookie is an additional allow path when remote-web-ui is loaded. The pet
4
+ * never depends on that plugin — without the service the fence stays
5
+ * loopback-only (same pattern as skill-explorer / aionui-panel).
6
+ */
7
+ import type { IncomingMessage } from 'node:http'
8
+ import type { Context } from '@deepseek-ai/cordis'
9
+ import { isLoopbackRequest } from './loopback.ts'
10
+
11
+ /** Structural pairing lookup (no package dependency on remote-web-ui). */
12
+ interface PairingAccess {
13
+ isPairedDevice(request: IncomingMessage): boolean
14
+ }
15
+
16
+ /** ctx.get is optional on the test harness; production Context always has it. */
17
+ type LookupCtx = Context & {
18
+ get?(name: string, strict?: boolean): unknown
19
+ remoteWebUiPairing?: PairingAccess
20
+ }
21
+
22
+ /**
23
+ * Whether this request may enter any /api/pet or /pet asset route.
24
+ * @param ctx - host context; may expose remoteWebUiPairing.
25
+ * @param request - the incoming HTTP request.
26
+ * @returns true for loopback, or a live paired-device cookie.
27
+ */
28
+ export function isPetAllowed(ctx: Context, request: IncomingMessage): boolean {
29
+ if (isLoopbackRequest(request)) return true
30
+ const bag = ctx as LookupCtx
31
+ const fromGet = typeof bag.get === 'function' ? bag.get('remoteWebUiPairing', false) : undefined
32
+ const pairing = (isPairingAccess(fromGet) ? fromGet : bag.remoteWebUiPairing)
33
+ return pairing?.isPairedDevice(request) === true
34
+ }
35
+
36
+ function isPairingAccess(value: unknown): value is PairingAccess {
37
+ return value !== undefined
38
+ && value !== null
39
+ && typeof (value as PairingAccess).isPairedDevice === 'function'
40
+ }
@@ -116,8 +116,14 @@ export class PetSettingsCardController {
116
116
  choiceField('petId', this.petChoices),
117
117
  ])
118
118
  this.store = this.form.bind(() => this.projection())
119
- void this.loadPets()
120
- void this.loadDiagnostics()
119
+ // Client plugins are applied synchronously during shell startup. Defer
120
+ // the first registry request until that pass completes so transport
121
+ // plugins (notably remote-web-ui on a paired non-loopback origin) can
122
+ // install their fetch channel before /api/pet/pets is issued.
123
+ window.setTimeout(() => {
124
+ void this.loadPets()
125
+ void this.loadDiagnostics()
126
+ }, 0)
121
127
  }
122
128
 
123
129
  /** Fetch registry diagnostics once (soft-fail: an empty list on error). */
@@ -755,6 +755,77 @@ describe('PetSprite status decoration (pet-center M5, #567)', () => {
755
755
  expect(el.style.backgroundPosition).toBe('-72px 0px')
756
756
  })
757
757
 
758
+ it('does not schedule a frame loop for a single-frame segment even when looping', () => {
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
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
772
+ frames.push(callback)
773
+ return frames.length
774
+ })
775
+ vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
776
+ // failed binds the single frame 3 of a 24px-wide frame; loop stays true.
777
+ renderPet({
778
+ snapshot: {
779
+ ...snapshot,
780
+ bubble: '失败',
781
+ phase: 'failed',
782
+ decoration: { ...decoration, phases: { ...decoration.phases, failed: { from: 3, to: 3 } } },
783
+ },
784
+ })
785
+ const el = ornament()!
786
+ // The ornament settles on its only frame, exactly like the reduced-motion
787
+ // hold — no rAF loop may start, so only the sprite's idle loop is pending.
788
+ expect(el.style.backgroundPosition).toBe('-72px 0px')
789
+ expect(frames).toHaveLength(1)
790
+ const step = (ts: number): void => { for (const callback of frames.splice(0)) callback(ts) }
791
+ act(() => { step(161) })
792
+ act(() => { step(322) })
793
+ // The frame never moves and the ornament never reschedules itself.
794
+ expect(el.style.backgroundPosition).toBe('-72px 0px')
795
+ expect(frames).toHaveLength(1)
796
+ })
797
+
798
+ it('does not advance the background while a frame is still in play', () => {
799
+ vi.spyOn(window, 'matchMedia').mockReturnValue({
800
+ matches: false,
801
+ media: '(prefers-reduced-motion: reduce)',
802
+ onchange: null,
803
+ addEventListener: () => {},
804
+ removeEventListener: () => {},
805
+ addListener: () => {},
806
+ removeListener: () => {},
807
+ dispatchEvent: () => false,
808
+ })
809
+ vi.spyOn(performance, 'now').mockReturnValue(0)
810
+ const frames: FrameRequestCallback[] = []
811
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
812
+ frames.push(callback)
813
+ return frames.length
814
+ })
815
+ vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
816
+ renderPet({ snapshot: { ...snapshot, bubble: '正在思考', phase: 'thinking', decoration } })
817
+ const el = ornament()!
818
+ const step = (ts: number): void => { for (const callback of frames.splice(0)) callback(ts) }
819
+ // thinking binds frames 0..3 at 160 ms/frame. The effect holds frame 0;
820
+ // a step at 80 ms — inside the first frame — must not move the ornament,
821
+ // and only crossing the 160 ms boundary advances to the next frame.
822
+ expect(el.style.backgroundPosition).toBe('0px 0px')
823
+ act(() => { step(80) })
824
+ expect(el.style.backgroundPosition).toBe('0px 0px')
825
+ act(() => { step(161) })
826
+ expect(el.style.backgroundPosition).toBe('-24px 0px')
827
+ })
828
+
758
829
  it('does not restart the frame loop when an equal-content decoration re-renders', () => {
759
830
  vi.spyOn(window, 'matchMedia').mockReturnValue({
760
831
  matches: false,
@@ -93,7 +93,11 @@ function StatusOrnament(props: { decoration: DecorationView; phase: ActivityPhas
93
93
  const position = (index: number): string => (-index * frameWidth) + 'px 0px'
94
94
  const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true
95
95
  el.style.backgroundPosition = position(segment.from)
96
- if (reduceMotion) return
96
+ // A single-frame segment (from === to) has nothing to animate: with
97
+ // loop=true the wrap branch would reset index to the same frame and the
98
+ // tick would keep rescheduling a no-op rAF forever. Settle on the one
99
+ // frame instead — same as the reduced-motion static hold.
100
+ if (reduceMotion || segment.from === segment.to) return
97
101
  let raf = 0
98
102
  let index = segment.from
99
103
  let elapsed = 0
@@ -107,8 +111,12 @@ function StatusOrnament(props: { decoration: DecorationView; phase: ActivityPhas
107
111
  elapsed = 0
108
112
  if (index < segment.to) index += 1
109
113
  else if (decoration.loop) index = segment.from
114
+ // Only advance the background when the frame actually changes:
115
+ // the segment's frame rate (duration ms, typically 90-160) is far
116
+ // below the rAF cadence, so writing the same position every frame
117
+ // would churn style recalculations for no visual change.
118
+ el.style.backgroundPosition = position(index)
110
119
  }
111
- el.style.backgroundPosition = position(index)
112
120
  // A non-looping segment settles on its last frame; stop scheduling
113
121
  // instead of repainting the same position every frame.
114
122
  if (!decoration.loop && index === segment.to) return
@@ -0,0 +1,72 @@
1
+ // @vitest-environment jsdom
2
+ import { act, cleanup, render } from '@testing-library/react'
3
+ import { afterEach, describe, expect, it, vi } from 'vitest'
4
+ import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
5
+ import type { PetRenderer, PetRendererContext } from '../../../contracts/renderer.ts'
6
+ import type { PetDefinition } from '../../../registry.ts'
7
+ import { defaultPetRendererRegistry } from '../registry.ts'
8
+ import type { Live2dErrorCode } from '../live2d.ts'
9
+ import { Live2dVisualMount } from './Live2dVisualMount.tsx'
10
+
11
+ const t = ((key: string) => key) as PropsLocale<'pet'>['t']
12
+ const noop = (): void => {}
13
+
14
+ function definition(id: string): PetDefinition {
15
+ return {
16
+ id,
17
+ displayName: id,
18
+ description: '',
19
+ renderer: 'live2d',
20
+ live2d: { modelPath: id + '.model3.json', modelUrl: '/pet/' + id + '/' + id + '.model3.json', motions: { idle: 'Idle' } },
21
+ } as unknown as PetDefinition
22
+ }
23
+
24
+ describe('Live2dVisualMount', () => {
25
+ afterEach(() => {
26
+ cleanup()
27
+ defaultPetRendererRegistry.clear()
28
+ })
29
+
30
+ it('clears a failed activation error when switching to another pet', () => {
31
+ const errorSinks: ((code: Live2dErrorCode) => void)[] = []
32
+ const disposes: ReturnType<typeof vi.fn>[] = []
33
+ defaultPetRendererRegistry.register({
34
+ id: 'live2d',
35
+ apiVersion: 'test',
36
+ validateConfig: (config) => config,
37
+ mount: (ctx: PetRendererContext) => {
38
+ const canvas = document.createElement('canvas')
39
+ ctx.container.appendChild(canvas)
40
+ const dispose = vi.fn(() => { canvas.remove() })
41
+ disposes.push(dispose)
42
+ return {
43
+ dispose,
44
+ tap: () => {},
45
+ onError: (listener: (code: Live2dErrorCode) => void) => { errorSinks.push(listener) },
46
+ }
47
+ },
48
+ } as PetRenderer)
49
+
50
+ const first = definition('broken')
51
+ const second = definition('healthy')
52
+ const { container, rerender, unmount } = render(<Live2dVisualMount definition={first} phase="idle" onPet={noop} t={t} />)
53
+ expect(container.querySelectorAll('canvas')).toHaveLength(1)
54
+ act(() => { errorSinks[0]?.('load-failed') })
55
+ expect(container.querySelector('[data-dsh-pet-live2d-error="load-failed"]')).toBeTruthy()
56
+
57
+ rerender(<Live2dVisualMount definition={second} phase="idle" onPet={noop} t={t} />)
58
+ expect(disposes[0]).toHaveBeenCalledTimes(1)
59
+ expect(container.querySelector('[data-dsh-pet-live2d="healthy"]')).toBeTruthy()
60
+ expect(container.querySelector('[data-dsh-pet-live2d-error]')).toBeNull()
61
+ expect(container.querySelectorAll('canvas')).toHaveLength(1)
62
+
63
+ rerender(<Live2dVisualMount definition={first} phase="idle" onPet={noop} t={t} />)
64
+ expect(disposes[1]).toHaveBeenCalledTimes(1)
65
+ expect(container.querySelector('[data-dsh-pet-live2d="broken"]')).toBeTruthy()
66
+ expect(container.querySelector('[data-dsh-pet-live2d-error]')).toBeNull()
67
+ expect(container.querySelectorAll('canvas')).toHaveLength(1)
68
+ unmount()
69
+ expect(disposes[2]).toHaveBeenCalledTimes(1)
70
+ expect(container.querySelectorAll('canvas')).toHaveLength(0)
71
+ })
72
+ })
@@ -33,6 +33,7 @@ export function Live2dVisualMount(props: {
33
33
 
34
34
  // One activation per pet definition: build the contract context and mount.
35
35
  useEffect(() => {
36
+ setError(null)
36
37
  const container = containerRef.current
37
38
  const live2d = props.definition.live2d
38
39
  if (container === null || live2d === undefined) return undefined
@@ -21,13 +21,18 @@ const VENDOR_URL = '/api/pet/runtime/live2d-vendor.js'
21
21
  export interface Live2dVendorApp {
22
22
  canvas: HTMLCanvasElement
23
23
  stage: { addChild(child: unknown): unknown }
24
- renderer: { readonly width: number; readonly height: number }
24
+ renderer: {
25
+ readonly width: number
26
+ readonly height: number
27
+ resize(width: number, height: number): void
28
+ }
25
29
  init(options: Record<string, unknown>): Promise<void>
26
- destroy(removeView?: boolean, options?: Record<string, unknown>): void
30
+ destroy(rendererOptions?: boolean | { removeView?: boolean; releaseGlobalResources?: boolean }, options?: Record<string, unknown>): void
27
31
  }
28
32
 
29
33
  /** The Live2DModel slice the renderer uses. */
30
34
  export interface Live2dVendorModel {
35
+ automator: { autoUpdate: boolean }
31
36
  anchor: { set(x: number, y?: number): void }
32
37
  position: { set(x: number, y: number): void }
33
38
  scale: { set(x: number, y?: number): void }
@@ -43,6 +48,7 @@ export interface Live2dVendorModel {
43
48
  expression(name?: string): unknown
44
49
  hitTest(x: number, y: number): string[]
45
50
  on(event: string, fn: () => void): unknown
51
+ destroy(options?: { children?: boolean; texture?: boolean; baseTexture?: boolean }): void
46
52
  }
47
53
 
48
54
  /** The vendor bundle global (window.__dshPetLive2d). */
@@ -20,7 +20,10 @@ import { createPhaseStream, type PhaseStream } from '../phase-stream.ts'
20
20
  import type { PetRendererContext } from '../../contracts/renderer.ts'
21
21
 
22
22
  interface FakeModel {
23
+ automator: { autoUpdate: boolean }
23
24
  calls: unknown[][]
25
+ destroyCalls: Record<string, unknown>[]
26
+ destroyed: number
24
27
  listeners: Record<string, () => void>
25
28
  width: number
26
29
  height: number
@@ -32,21 +35,58 @@ interface FakeModel {
32
35
  expression(name?: string): void
33
36
  hitTest(x: number, y: number): string[]
34
37
  on(event: string, fn: () => void): void
38
+ destroy(options?: Record<string, unknown>): void
35
39
  }
36
40
 
37
41
  interface FakeApp {
38
42
  canvas: HTMLCanvasElement
39
43
  stage: { addChild(child: unknown): void }
40
- renderer: { width: number; height: number }
44
+ renderer: { width: number; height: number; resize(width: number, height: number): void }
41
45
  init(options: Record<string, unknown>): Promise<void>
42
- destroy(): void
46
+ destroy(rendererOptions?: boolean | { removeView?: boolean; releaseGlobalResources?: boolean }, options?: Record<string, unknown>): void
43
47
  destroyed: number
48
+ destroyCalls: unknown[][]
44
49
  added: unknown
50
+ resizeCalls: [number, number][]
51
+ }
52
+
53
+ class FakeResizeObserver {
54
+ static instances: FakeResizeObserver[] = []
55
+
56
+ readonly targets = new Set<Element>()
57
+ disconnected = false
58
+
59
+ constructor(private readonly callback: ResizeObserverCallback) {
60
+ FakeResizeObserver.instances.push(this)
61
+ }
62
+
63
+ observe(target: Element): void {
64
+ this.targets.add(target)
65
+ }
66
+
67
+ unobserve(target: Element): void {
68
+ this.targets.delete(target)
69
+ }
70
+
71
+ disconnect(): void {
72
+ this.disconnected = true
73
+ this.targets.clear()
74
+ }
75
+
76
+ emit(target: Element, width: number, height: number): void {
77
+ this.callback([{
78
+ target,
79
+ contentRect: { width, height },
80
+ } as ResizeObserverEntry], this as unknown as ResizeObserver)
81
+ }
45
82
  }
46
83
 
47
84
  function fakeModel(motions: Record<string, unknown[]> = { Idle: [{}, {}], TapBody: [{}] }): FakeModel {
48
85
  const model: FakeModel = {
86
+ automator: { autoUpdate: false },
49
87
  calls: [],
88
+ destroyCalls: [],
89
+ destroyed: 0,
50
90
  listeners: {},
51
91
  width: 1000,
52
92
  height: 1200,
@@ -58,19 +98,50 @@ function fakeModel(motions: Record<string, unknown[]> = { Idle: [{}, {}], TapBod
58
98
  expression: (name) => { model.calls.push(['expression', name]) },
59
99
  hitTest: (x, y) => { model.calls.push(['hitTest', x, y]); return ['Body'] },
60
100
  on: (event, fn) => { model.listeners[event] = fn },
101
+ destroy: (options = {}) => {
102
+ model.destroyed += 1
103
+ model.destroyCalls.push(options)
104
+ model.automator.autoUpdate = false
105
+ },
61
106
  }
62
107
  return model
63
108
  }
64
109
 
65
110
  function fakeApp(): FakeApp {
111
+ const canvas = document.createElement('canvas')
66
112
  const app: FakeApp = {
67
- canvas: document.createElement('canvas'),
113
+ canvas,
68
114
  stage: { addChild: (child) => { app.added = child } },
69
- renderer: { width: 160, height: 174 },
70
- init: () => Promise.resolve(),
71
- destroy: () => { app.destroyed += 1 },
115
+ renderer: {
116
+ width: 160,
117
+ height: 174,
118
+ resize: (width, height) => {
119
+ app.renderer.width = width
120
+ app.renderer.height = height
121
+ canvas.width = width
122
+ canvas.height = height
123
+ app.resizeCalls.push([width, height])
124
+ },
125
+ },
126
+ init: (options) => {
127
+ app.renderer.width = Number(options.width)
128
+ app.renderer.height = Number(options.height)
129
+ canvas.width = app.renderer.width
130
+ canvas.height = app.renderer.height
131
+ return Promise.resolve()
132
+ },
133
+ destroy: (rendererOptions, options) => {
134
+ app.destroyed += 1
135
+ app.destroyCalls.push([rendererOptions, options])
136
+ if (rendererOptions === true || (typeof rendererOptions === 'object' && rendererOptions.removeView === true)) app.canvas.remove()
137
+ if (options?.children === true && typeof (app.added as { destroy?: unknown } | undefined)?.destroy === 'function') {
138
+ ;(app.added as { destroy(options?: Record<string, unknown>): void }).destroy(options)
139
+ }
140
+ },
72
141
  destroyed: 0,
142
+ destroyCalls: [],
73
143
  added: undefined,
144
+ resizeCalls: [],
74
145
  }
75
146
  return app
76
147
  }
@@ -78,7 +149,10 @@ function fakeApp(): FakeApp {
78
149
  function fakeVendor(
79
150
  model: FakeModel,
80
151
  app: FakeApp,
81
- from: (source: string, options?: Record<string, unknown>) => Promise<FakeModel> = () => Promise.resolve(model),
152
+ from: (source: string, options?: Record<string, unknown>) => Promise<FakeModel> = (_source, options) => {
153
+ model.calls.push(['from', options])
154
+ return Promise.resolve(model)
155
+ },
82
156
  ): unknown {
83
157
  return {
84
158
  Application: class { constructor() { return app } },
@@ -91,12 +165,27 @@ function fakeVendor(
91
165
 
92
166
  const CONFIG = { modelUrl: '/pet/haru/haru.model3.json', motions: { idle: 'Idle', thinking: 'TapBody' } }
93
167
 
94
- function makeCtx(): { ctx: PetRendererContext; stream: PhaseStream; container: HTMLDivElement } {
168
+ function makeCtx(width = 0, height = 0): {
169
+ ctx: PetRendererContext
170
+ stream: PhaseStream
171
+ container: HTMLDivElement
172
+ setSize(nextWidth: number, nextHeight: number): void
173
+ } {
95
174
  const container = document.createElement('div')
175
+ let clientWidth = width
176
+ let clientHeight = height
177
+ Object.defineProperties(container, {
178
+ clientWidth: { configurable: true, get: () => clientWidth },
179
+ clientHeight: { configurable: true, get: () => clientHeight },
180
+ })
96
181
  const stream = createPhaseStream('idle')
97
182
  return {
98
183
  container,
99
184
  stream,
185
+ setSize(nextWidth, nextHeight) {
186
+ clientWidth = nextWidth
187
+ clientHeight = nextHeight
188
+ },
100
189
  ctx: {
101
190
  petId: 'haru',
102
191
  assetBase: '/pet/haru',
@@ -115,9 +204,12 @@ async function flush(): Promise<void> {
115
204
  describe('live2dRenderer', () => {
116
205
  beforeEach(() => {
117
206
  runtime.core = true
207
+ FakeResizeObserver.instances = []
208
+ vi.stubGlobal('ResizeObserver', FakeResizeObserver)
118
209
  resetLive2dRenderer()
119
210
  })
120
211
  afterEach(() => {
212
+ vi.unstubAllGlobals()
121
213
  vi.restoreAllMocks()
122
214
  })
123
215
 
@@ -131,6 +223,8 @@ describe('live2dRenderer', () => {
131
223
  await flush()
132
224
  expect(container.querySelector('canvas')).toBeTruthy()
133
225
  expect(app.added).toBe(model)
226
+ expect(model.calls).toContainEqual(['from', expect.objectContaining({ autoUpdate: false, autoHitTest: false, autoFocus: false })])
227
+ expect(model.automator.autoUpdate).toBe(true)
134
228
  // idle plays on boot: group 'Idle', random index floor(0.99 * 2) = 1.
135
229
  expect(model.calls).toContainEqual(['motion', 'Idle', 1])
136
230
  // auto-fit: min(160/1000, 174/1200) * 0.92 = 0.1334
@@ -142,8 +236,14 @@ describe('live2dRenderer', () => {
142
236
  expect(model.calls.filter(call => call[0] === 'motion').at(-1)).toEqual(['motion', 'Idle', 1])
143
237
  handle.dispose()
144
238
  expect(app.destroyed).toBe(1)
239
+ expect(app.destroyCalls[0]?.[0]).toEqual({ removeView: true })
240
+ expect(model.destroyed).toBe(1)
241
+ expect(model.destroyCalls).toContainEqual({ children: true })
242
+ expect(model.automator.autoUpdate).toBe(false)
145
243
  handle.dispose() // idempotent
146
244
  expect(app.destroyed).toBe(1)
245
+ expect(app.destroyCalls[0]?.[0]).toEqual({ removeView: true })
246
+ expect(model.destroyed).toBe(1)
147
247
  })
148
248
 
149
249
  it('uses one automatic texture LOD instead of the default full mip chain', async () => {
@@ -157,12 +257,79 @@ describe('live2dRenderer', () => {
157
257
  await flush()
158
258
 
159
259
  expect(from).toHaveBeenCalledWith(CONFIG.modelUrl, {
260
+ autoUpdate: false,
160
261
  autoHitTest: false,
161
262
  autoFocus: false,
162
263
  textureOptions: { lod: 'single-auto' },
163
264
  })
164
265
  })
165
266
 
267
+ it('keeps the Pixi canvas and model layout in sync with display-size changes', async () => {
268
+ const model = fakeModel()
269
+ const app = fakeApp()
270
+ runtime.vendor = fakeVendor(model, app)
271
+ const { ctx, container, setSize } = makeCtx(148, 160)
272
+
273
+ const handle = live2dRenderer.mount(ctx, live2dRenderer.validateConfig(CONFIG)) as Live2dRendererHandle
274
+ await flush()
275
+ expect(app.canvas.width).toBe(148)
276
+ expect(app.canvas.height).toBe(160)
277
+ expect(model.calls).toContainEqual(['position', 74, 80])
278
+
279
+ model.calls.length = 0
280
+ setSize(295, 320)
281
+ FakeResizeObserver.instances[0]?.emit(container, 295, 320)
282
+
283
+ expect(app.resizeCalls).toEqual([[295, 320]])
284
+ expect(app.canvas.width).toBe(295)
285
+ expect(app.canvas.height).toBe(320)
286
+ expect(model.calls).toContainEqual(['scale', expect.closeTo(0.245333, 5)])
287
+ expect(model.calls).toContainEqual(['position', 147.5, 160])
288
+ handle.tap(147.5, 160)
289
+ expect(model.calls).toContainEqual(['hitTest', 147.5, 160])
290
+
291
+ handle.dispose()
292
+ expect(FakeResizeObserver.instances[0]?.disconnected).toBe(true)
293
+ })
294
+
295
+ it('uses the latest renderer size when the model finishes loading', async () => {
296
+ const model = fakeModel()
297
+ const app = fakeApp()
298
+ let resolveModel!: (value: FakeModel) => void
299
+ const pendingModel = new Promise<FakeModel>((resolve) => { resolveModel = resolve })
300
+ runtime.vendor = fakeVendor(model, app, () => pendingModel)
301
+ const { ctx, container, setSize } = makeCtx(148, 160)
302
+
303
+ live2dRenderer.mount(ctx, live2dRenderer.validateConfig(CONFIG))
304
+ await flush()
305
+ setSize(295, 320)
306
+ FakeResizeObserver.instances[0]?.emit(container, 295, 320)
307
+ resolveModel(model)
308
+ await flush()
309
+
310
+ expect(app.resizeCalls).toEqual([[295, 320]])
311
+ expect(model.calls).toContainEqual(['position', 147.5, 160])
312
+ expect(model.calls).toContainEqual(['scale', expect.closeTo(0.245333, 5)])
313
+ })
314
+
315
+ it('ignores zero-size observations and stops resizing after disposal', async () => {
316
+ const model = fakeModel()
317
+ const app = fakeApp()
318
+ runtime.vendor = fakeVendor(model, app)
319
+ const { ctx, container } = makeCtx(148, 160)
320
+ const handle = live2dRenderer.mount(ctx, live2dRenderer.validateConfig(CONFIG))
321
+ await flush()
322
+ const observer = FakeResizeObserver.instances[0]
323
+
324
+ observer?.emit(container, 0, 0)
325
+ observer?.emit(container, 148, 160)
326
+ expect(app.resizeCalls).toEqual([])
327
+
328
+ handle.dispose()
329
+ observer?.emit(container, 295, 320)
330
+ expect(app.resizeCalls).toEqual([])
331
+ })
332
+
166
333
  it('falls back to the idle group when a mapped group is absent from the model', async () => {
167
334
  const model = fakeModel({ Idle: [{}] })
168
335
  runtime.vendor = fakeVendor(model, fakeApp())
@@ -239,7 +406,43 @@ describe('live2dRenderer', () => {
239
406
  handle.onError((next) => { code = next })
240
407
  await flush()
241
408
  expect(code).toBe('load-failed')
409
+ expect(app.destroyed).toBe(1)
410
+ expect(ctx.container.querySelector('canvas')).toBeNull()
242
411
  handle.dispose()
412
+ expect(app.destroyed).toBe(1)
413
+ })
414
+
415
+ it('destroys both sides exactly once when disposed while model loading is pending', async () => {
416
+ const model = fakeModel()
417
+ const app = fakeApp()
418
+ let resolveModel!: (value: FakeModel) => void
419
+ const pendingModel = new Promise<FakeModel>((resolve) => { resolveModel = resolve })
420
+ const from = vi.fn(() => pendingModel)
421
+ runtime.vendor = {
422
+ Application: class { constructor() { return app } },
423
+ extensions: { add: () => {} },
424
+ Live2DPlugin: {},
425
+ configureCubismSDK: () => {},
426
+ Live2DModel: { from },
427
+ }
428
+ const { ctx, container } = makeCtx()
429
+ const handle = live2dRenderer.mount(ctx, live2dRenderer.validateConfig(CONFIG))
430
+ let error: string | undefined
431
+ ;(handle as Live2dRendererHandle).onError((code) => { error = code })
432
+ await flush()
433
+ expect(from).toHaveBeenCalledTimes(1)
434
+ expect(container.querySelector('canvas')).toBeTruthy()
435
+ handle.dispose()
436
+ expect(app.destroyed).toBe(1)
437
+ expect(container.querySelector('canvas')).toBeNull()
438
+ resolveModel(model)
439
+ await flush()
440
+ expect(app.destroyed).toBe(1)
441
+ expect(model.destroyed).toBe(1)
442
+ expect(model.destroyCalls).toContainEqual({ children: true })
443
+ expect(model.automator.autoUpdate).toBe(false)
444
+ expect(app.added).toBeUndefined()
445
+ expect(error).toBeUndefined()
243
446
  })
244
447
 
245
448
  it('never appends a canvas when disposed mid-boot', async () => {