@octane-xplat/motion 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/pan.web.ts ADDED
@@ -0,0 +1,116 @@
1
+ import type { PanCallbacks } from './host-types'
2
+ import type { DragInfo } from './types'
3
+
4
+ export function attachPan(node: HTMLElement, axis: true | 'x' | 'y', callbacks: PanCallbacks) {
5
+ const touchAction = node.style.touchAction
6
+ node.style.touchAction = axis === 'x' ? 'pan-y' : axis === 'y' ? 'pan-x' : 'none'
7
+ let pointer: number | undefined
8
+ let active = false
9
+ let start = { x: 0, y: 0 }
10
+ let last = start
11
+ let time = 0
12
+ let velocity = { x: 0, y: 0 }
13
+ let velocityTime = 0
14
+ const sample = (event: PointerEvent, cancelled = false): DragInfo => {
15
+ const point = cancelled ? last : { x: event.clientX, y: event.clientY }
16
+ const delta = { x: point.x - last.x, y: point.y - last.y }
17
+ const elapsed = event.timeStamp - time
18
+ if (elapsed > 0 && (delta.x || delta.y)) {
19
+ velocity = { x: (delta.x * 1000) / elapsed, y: (delta.y * 1000) / elapsed }
20
+ velocityTime = event.timeStamp
21
+ } else if (event.timeStamp - velocityTime > 100) {
22
+ velocity = { x: 0, y: 0 }
23
+ }
24
+
25
+ last = point
26
+ time = event.timeStamp
27
+ return {
28
+ point,
29
+ delta,
30
+ offset: { x: point.x - start.x, y: point.y - start.y },
31
+ velocity,
32
+ cancelled,
33
+ }
34
+ }
35
+
36
+ const down = (event: PointerEvent) => {
37
+ if (pointer !== undefined || event.button !== 0 || event.isPrimary === false) {
38
+ return
39
+ }
40
+
41
+ pointer = event.pointerId
42
+ start = last = { x: event.clientX, y: event.clientY }
43
+ time = velocityTime = event.timeStamp
44
+ velocity = { x: 0, y: 0 }
45
+ try {
46
+ node.setPointerCapture?.(event.pointerId)
47
+ } catch (error) {
48
+ // Synthetic pointer dispatch has no active browser pointer.
49
+ if (event.isTrusted || (error as Error).name !== 'NotFoundError') {
50
+ throw error
51
+ }
52
+ }
53
+ }
54
+
55
+ const move = (event: PointerEvent) => {
56
+ if (event.pointerId !== pointer) {
57
+ return
58
+ }
59
+
60
+ const info = sample(event)
61
+ if (!active) {
62
+ const primary = axis === 'y' ? info.offset.y : info.offset.x
63
+ const other = axis === 'y' ? info.offset.x : info.offset.y
64
+ if (axis !== true && Math.abs(other) > 8 && Math.abs(primary) <= 8) {
65
+ finish(event, true)
66
+ return
67
+ }
68
+
69
+ if ((axis === true ? Math.hypot(info.offset.x, info.offset.y) : Math.abs(primary)) <= 8) {
70
+ return
71
+ }
72
+
73
+ active = true
74
+ callbacks.start(event, info)
75
+ }
76
+
77
+ callbacks.move(event, info)
78
+ }
79
+
80
+ const finish = (event: PointerEvent, cancelled: boolean) => {
81
+ if (pointer === undefined || event.pointerId !== pointer) {
82
+ return
83
+ }
84
+
85
+ const id = pointer
86
+ pointer = undefined
87
+ if (active) {
88
+ active = false
89
+ callbacks.end(event, sample(event, cancelled))
90
+ }
91
+
92
+ if (node.hasPointerCapture?.(id)) {
93
+ node.releasePointerCapture(id)
94
+ }
95
+ }
96
+
97
+ const up = (event: PointerEvent) => finish(event, false)
98
+ const cancel = (event: PointerEvent) => finish(event, true)
99
+ node.addEventListener('pointerdown', down)
100
+ node.addEventListener('pointermove', move)
101
+ node.addEventListener('pointerup', up)
102
+ node.addEventListener('pointercancel', cancel)
103
+ node.addEventListener('lostpointercapture', cancel)
104
+ return () => {
105
+ node.removeEventListener('pointerdown', down)
106
+ node.removeEventListener('pointermove', move)
107
+ node.removeEventListener('pointerup', up)
108
+ node.removeEventListener('pointercancel', cancel)
109
+ node.removeEventListener('lostpointercapture', cancel)
110
+ if (pointer !== undefined && node.hasPointerCapture?.(pointer)) {
111
+ node.releasePointerCapture(pointer)
112
+ }
113
+
114
+ node.style.touchAction = touchAction
115
+ }
116
+ }
package/src/presence.tsrx CHANGED
@@ -11,13 +11,13 @@ export function Presence(props: PresenceProps) @{
11
11
  const [retained, setRetained] = useState(props.present)
12
12
  const latest = useRef(props)
13
13
  latest.current = props
14
- const ref = useRef<PresenceState | null>(null)
15
- if (!ref.current) ref.current = new PresenceState(props.present, () => {
14
+ const stateRef = useRef<PresenceState | null>(null)
15
+ if (!stateRef.current) stateRef.current = new PresenceState(props.present, () => {
16
16
  setRetained(false)
17
17
  latest.current.onExitComplete?.()
18
18
  })
19
19
 
20
- const state = ref.current
20
+ const state = stateRef.current
21
21
  const host = useRef<any>(null)
22
22
  const restoreInput = useRef<(() => void) | null>(null)
23
23
  const syncInput = () => {
@@ -38,14 +38,13 @@ export function Presence(props: PresenceProps) @{
38
38
  host.current = null
39
39
  }, [])
40
40
 
41
- const { present, onExitComplete, children, bind, ...viewProps } = props
41
+ const { present, onExitComplete, children, ref, ...viewProps } = props
42
42
 
43
43
  @if (props.present || retained) {
44
- <View {...viewProps} bind={(node: any) => {
44
+ <View {...viewProps} ref={[(node: any) => {
45
45
  if (host.current !== node) { restoreInput.current?.(); restoreInput.current = null; host.current = node }
46
46
  syncInput()
47
- bind?.(node)
48
- }}>
47
+ }, ref]}>
49
48
  <PresenceContext value={state}>{children}</PresenceContext>
50
49
  </View>
51
50
  }
@@ -1,4 +1,4 @@
1
- import { Application, Utils } from '@nativescript/core'
1
+ import { Application, Device, Utils } from '@nativescript/core'
2
2
 
3
3
  export function readReducedMotion(): boolean {
4
4
  const platform = globalThis as any
@@ -6,7 +6,19 @@ export function readReducedMotion(): boolean {
6
6
  return Boolean(platform.UIAccessibilityIsReduceMotionEnabled?.())
7
7
  }
8
8
 
9
- const context = Utils.android.getApplicationContext()
9
+ if (!platform.android) {
10
+ return false
11
+ }
12
+
13
+ if (
14
+ Number(Device.sdkVersion) >= 26 &&
15
+ platform.android.animation?.ValueAnimator?.areAnimatorsEnabled?.() === false
16
+ ) {
17
+ return true
18
+ }
19
+
20
+ // `Utils.android` is a {} stub in the pinned Windows core build.
21
+ const context = (Utils.android as any).getApplicationContext()
10
22
  return (
11
23
  platform.android.provider.Settings.Global.getFloat(
12
24
  context.getContentResolver(),
@@ -16,9 +28,16 @@ export function readReducedMotion(): boolean {
16
28
  )
17
29
  }
18
30
 
19
- // Poll only while subscribed; this also detects Android developer-setting changes
20
- // without owning a Java ContentObserver class. Resume refreshes immediately.
31
+ // iOS posts a notification on preference change; Android has no event, so it
32
+ // polls only while subscribed (also catches developer-setting changes) and
33
+ // refreshes on resume.
21
34
  export function observeReducedMotion(notify: () => void): () => void {
35
+ if (Application.ios) {
36
+ const name = (globalThis as any).UIAccessibilityReduceMotionStatusDidChangeNotification
37
+ const observer = Application.ios.addNotificationObserver(name, notify)
38
+ return () => Application.ios.removeNotificationObserver(observer, name)
39
+ }
40
+
22
41
  let previous = readReducedMotion()
23
42
  const check = () => {
24
43
  const next = readReducedMotion()
package/src/types.ts CHANGED
@@ -1,8 +1,8 @@
1
- import type { View, Row, Pressable } from '@octane-xplat/ui'
1
+ import type { View, HStack, Pressable } from '@octane-xplat/ui'
2
2
  import type { MotionValue } from './value.js'
3
3
 
4
4
  type ViewProps = Parameters<typeof View>[0]
5
- type RowProps = Parameters<typeof Row>[0]
5
+ type HStackProps = Parameters<typeof HStack>[0]
6
6
  type PressableProps = Parameters<typeof Pressable>[0]
7
7
 
8
8
  /** Supported channels. Translation is CSS pixels/DIP; rotation is degrees. */
@@ -10,13 +10,43 @@ export type MotionKey = 'opacity' | 'x' | 'y' | 'scale' | 'scaleX' | 'scaleY' |
10
10
  /** Absolute numeric destinations; omitted channels retain their current values. */
11
11
  export type Target = Partial<Record<MotionKey, number>>
12
12
  /** Tween easing, including a CSS-compatible cubic Bezier tuple. */
13
- export type Ease = 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' | [number, number, number, number]
13
+ export type Ease =
14
+ | 'linear'
15
+ | 'easeIn'
16
+ | 'easeOut'
17
+ | 'easeInOut'
18
+ | 'circIn'
19
+ | 'circOut'
20
+ | 'circInOut'
21
+ | 'backIn'
22
+ | 'backOut'
23
+ | 'backInOut'
24
+ | 'anticipate'
25
+ | [number, number, number, number]
26
+
27
+ /** How repeated legs play: restart, alternate direction, or alternate
28
+ * direction with inverted easing (identical to reverse for two keyframes). */
29
+ export type RepeatType = 'loop' | 'reverse' | 'mirror'
30
+ /** Numeric tree timing in seconds, applied to inherited animate labels. */
31
+ export interface ChildOrchestration {
32
+ staggerChildren?: number
33
+ delayChildren?: number
34
+ when?: 'beforeChildren' | 'afterChildren'
35
+ }
36
+
14
37
  /** Timing is seconds. Spring velocity is units per second. */
15
- export interface Transition {
38
+ export interface Transition extends ChildOrchestration {
16
39
  type?: 'tween' | 'spring'
40
+ /** Tween duration; on springs this is the visual duration (combine with bounce). */
17
41
  duration?: number
18
42
  delay?: number
19
43
  ease?: Ease
44
+ /** Additional iterations after the first leg; Infinity loops forever. */
45
+ repeat?: number
46
+ repeatType?: RepeatType
47
+ repeatDelay?: number
48
+ /** Spring bounciness 0–1; pairs with duration instead of stiffness/damping. */
49
+ bounce?: number
20
50
  stiffness?: number
21
51
  damping?: number
22
52
  mass?: number
@@ -25,28 +55,87 @@ export interface Transition {
25
55
  restDelta?: number
26
56
  }
27
57
 
58
+ /** Per-channel transition overrides, upstream form: `{x: {…}, default: {…}}`. */
59
+ export type TransitionOrchestration = ChildOrchestration & {
60
+ default?: Transition
61
+ } & Partial<Record<MotionKey, Transition>>
62
+
63
+ /** A flat transition or per-channel overrides. */
64
+ export type TransitionInput = Transition | TransitionOrchestration
65
+
66
+ /** Labels resolve against this host's variants, in array order. */
67
+ export type VariantLabels = string | string[]
68
+ /** An inline numeric target or labels resolved by the host. */
69
+ export type AnimationDefinition = Target | VariantLabels
70
+ /** A numeric variant destination with an optional run transition override. */
71
+ export type VariantTarget = Target & { transition?: TransitionInput }
72
+ /** Host-local definitions; pure resolvers receive this host's custom value. */
73
+ export type Variants = Record<string, VariantTarget | ((custom: any) => VariantTarget)>
74
+
28
75
  /** Static platform styles plus numeric motion-value bindings. */
29
76
  export type MotionStyle = Record<string, unknown> & Partial<Record<MotionKey, number | MotionValue>>
77
+ /** Translation limits in CSS pixels/DIP. Omitted edges are unbounded. */
78
+ export interface DragConstraints {
79
+ left?: number
80
+ right?: number
81
+ top?: number
82
+ bottom?: number
83
+ }
84
+
85
+ export interface DragInfo {
86
+ point: { x: number; y: number }
87
+ delta: { x: number; y: number }
88
+ offset: { x: number; y: number }
89
+ /** CSS pixels/DIP per second. */
90
+ velocity: { x: number; y: number }
91
+ cancelled: boolean
92
+ }
93
+
94
+ export type DragCallback = (event: any, info: DragInfo) => void
95
+
30
96
  /** Supported declarative motion controls. */
31
97
  export interface MotionProps {
32
- initial?: Target | false
98
+ drag?: boolean | 'x' | 'y'
99
+ /** Numeric translation bounds; measured ref constraints are deferred. */
100
+ dragConstraints?: DragConstraints
101
+ /** Project release velocity into a spring destination (default true). */
102
+ dragMomentum?: boolean
103
+ /** Resistance beyond bounds, 0–1 (default 0.35); false = 0, true = 0.35. */
104
+ dragElastic?: boolean | number
105
+ onDragStart?: DragCallback
106
+ onDrag?: DragCallback
107
+ onDragEnd?: DragCallback
108
+ /** Named numeric targets; array labels merge left to right. */
109
+ variants?: Variants
110
+ /** Passed to this host's variant resolvers; not inherited. */
111
+ custom?: any
112
+ initial?: AnimationDefinition | false
33
113
  /** Destination while the nearest Presence boundary retains this host for exit. */
34
- exit?: Target
35
- animate?: Target
36
- transition?: Transition
114
+ exit?: AnimationDefinition
115
+ animate?: AnimationDefinition
116
+ transition?: TransitionInput
37
117
  style?: MotionStyle
118
+ /** Target while the pointer is down (web: pointerdown; native: touch down). */
119
+ whileTap?: AnimationDefinition
120
+ whileTapTransition?: Transition
121
+ /** Target while the host holds focus. */
122
+ whileFocus?: AnimationDefinition
123
+ whileFocusTransition?: Transition
124
+ onAnimationStart?: (definition: AnimationDefinition) => void
38
125
  onAnimationComplete?: () => void
126
+ /** Per-frame snapshot while any channel animates. */
127
+ onUpdate?: (latest: Target) => void
39
128
  }
40
129
 
41
130
  /** View layout and accessibility props with motion controls. */
42
131
  export type MotionViewProps = Omit<ViewProps, 'style'> & MotionProps
43
132
  /** Row layout and accessibility props with motion controls. */
44
- export type MotionRowProps = Omit<RowProps, 'style'> & MotionProps
133
+ export type MotionRowProps = Omit<HStackProps, 'style'> & MotionProps
45
134
  /** Pressable interaction props with motion controls. */
46
135
  export type MotionPressableProps = Omit<PressableProps, 'style'> & MotionProps
47
136
  /** Inherited defaults; `user` observes the live system preference. */
48
137
  export interface MotionConfigProps {
49
- transition?: Transition
138
+ transition?: TransitionInput
50
139
  reducedMotion?: 'always' | 'never' | 'user'
51
140
  children?: any
52
141
  }
package/src/value.ts CHANGED
@@ -88,6 +88,15 @@ export class MotionValue {
88
88
  }
89
89
  }
90
90
  private write(value: number) {
91
+ this.track(value)
92
+ if (!this.disposed && value !== this.previous) {
93
+ this.emit('change', value)
94
+ }
95
+ }
96
+ /** Update position bookkeeping without emitting — platform-delegated runs
97
+ * track presentation state so interruption reads fresh value/velocity. */
98
+ track(value: number) {
99
+ this.assert(value)
91
100
  if (this.disposed) {
92
101
  return
93
102
  }
@@ -101,12 +110,13 @@ export class MotionValue {
101
110
  this.previous = this.current
102
111
  this.current = value
103
112
  this.updated = now
104
- if (value !== this.previous) {
105
- this.emit('change', value)
106
- }
107
113
  }
108
114
  /** Animate to a destination. New playback replaces old playback. */
109
- animate(target: number, transition: Transition = {}): AnimationControls {
115
+ animate(
116
+ target: number,
117
+ transition: Transition = {},
118
+ clampSample?: (value: number) => number,
119
+ ): AnimationControls {
110
120
  this.assert(target)
111
121
  validateTransition(transition)
112
122
  if (this.disposed) {
@@ -122,7 +132,7 @@ export class MotionValue {
122
132
  this.current,
123
133
  target,
124
134
  { ...transition, velocity },
125
- (value) => this.write(value),
135
+ (value) => this.write(clampSample ? clampSample(value) : value),
126
136
  )
127
137
 
128
138
  this.controls = controls
@@ -0,0 +1,197 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { resolveVariant, playbackTransition, VariantNode } from './variants'
3
+ import type { AnimationResult } from './engine'
4
+ import type { TransitionInput } from './types'
5
+
6
+ function node(timing: TransitionInput = {}) {
7
+ const value = new VariantNode()
8
+ value.resolve = () => ({ target: { x: 10 }, transition: timing })
9
+ value.play = vi.fn(async () => 'finished' as AnimationResult)
10
+ value.stopOwn = vi.fn()
11
+ return value
12
+ }
13
+
14
+ function pending() {
15
+ let resolve!: (result: AnimationResult) => void
16
+ const promise = new Promise<AnimationResult>((done) => {
17
+ resolve = done
18
+ })
19
+
20
+ return { promise, resolve }
21
+ }
22
+
23
+ describe('variant resolution', () => {
24
+ it('merges arrays left to right and keeps the last defined transition', () => {
25
+ expect(
26
+ resolveVariant(['missing', 'a', 'b', 'c'], {
27
+ a: { x: 10, opacity: 0, transition: { duration: 1 } },
28
+ b: { x: 20, transition: { duration: 2 } },
29
+ c: { y: 30 },
30
+ }),
31
+ ).toEqual({ target: { x: 20, opacity: 0, y: 30 }, transition: { duration: 2 } })
32
+ })
33
+
34
+ it('passes custom to resolvers and validates the returned numeric target', () => {
35
+ expect(resolveVariant('a', { a: (custom) => ({ x: custom }) }, 7).target).toEqual({ x: 7 })
36
+ expect(() => resolveVariant('a', { a: () => ({ x: '10px' as any }) })).toThrow('finite numeric')
37
+ expect(() => resolveVariant('a', { a: { transition: { staggerChildren: -1 } } })).toThrow(
38
+ 'staggerChildren',
39
+ )
40
+
41
+ expect(resolveVariant('toString', {}).target).toEqual({})
42
+ })
43
+
44
+ it('strips parent timing and adds delay to each per-key override', () => {
45
+ expect(
46
+ playbackTransition(
47
+ {
48
+ default: { duration: 1, delay: 0.1 },
49
+ x: { delay: 0.2 },
50
+ delayChildren: 1,
51
+ staggerChildren: 2,
52
+ when: 'afterChildren',
53
+ },
54
+ 0.3,
55
+ ),
56
+ ).toEqual({ default: { duration: 1, delay: 0.4 }, x: { delay: 0.5 } })
57
+ })
58
+ })
59
+
60
+ describe('variant orchestration', () => {
61
+ it('starts parent and children concurrently with stable stagger offsets', async () => {
62
+ const parent = node({ delayChildren: 0.1, staggerChildren: 0.2 })
63
+ const a = node(),
64
+ b = node()
65
+
66
+ parent.register(a)
67
+ parent.register(b)
68
+ const job = pending()
69
+ parent.play = vi.fn(() => job.promise)
70
+ const result = parent.run('show')
71
+ expect(a.play).toHaveBeenCalledWith('show', { x: 10 }, { delay: 0.1 })
72
+ expect(b.play).toHaveBeenCalledWith('show', { x: 10 }, { delay: 0.30000000000000004 })
73
+ job.resolve('finished')
74
+ expect(await result).toBe('finished')
75
+ })
76
+
77
+ it('waits for actual parent completion before children', async () => {
78
+ const parent = node({ when: 'beforeChildren' }),
79
+ child = node()
80
+
81
+ parent.register(child)
82
+ const job = pending()
83
+ parent.play = vi.fn(() => job.promise)
84
+ const result = parent.run('show')
85
+ expect(child.play).not.toHaveBeenCalled()
86
+ job.resolve('finished')
87
+ expect(await result).toBe('finished')
88
+ expect(child.play).toHaveBeenCalledOnce()
89
+ })
90
+
91
+ it('waits for children and cancels queued parent work on disposal', async () => {
92
+ const parent = node({ when: 'afterChildren' }),
93
+ child = node()
94
+
95
+ parent.register(child)
96
+ const job = pending()
97
+ child.play = vi.fn(() => job.promise)
98
+ const result = parent.run('show')
99
+ expect(parent.play).not.toHaveBeenCalled()
100
+ parent.stop()
101
+ job.resolve('finished')
102
+ expect(await result).toBe('cancelled')
103
+ expect(parent.play).not.toHaveBeenCalled()
104
+ })
105
+
106
+ it('unregisters children and propagates cancellation through nested runs', async () => {
107
+ const parent = node(),
108
+ child = node(),
109
+ grandchild = node()
110
+
111
+ child.register(grandchild)
112
+ const off = parent.register(child)
113
+ await parent.run('show')
114
+ expect(grandchild.play).toHaveBeenCalledOnce()
115
+ off()
116
+ await parent.run('hide')
117
+ expect(child.play).toHaveBeenCalledOnce()
118
+ expect(child.stopOwn).toHaveBeenCalled()
119
+ })
120
+ })
121
+
122
+ it('starts the parent only after every child finishes successfully', async () => {
123
+ const parent = node({ when: 'afterChildren' })
124
+ const a = node(),
125
+ b = node()
126
+
127
+ parent.register(a)
128
+ parent.register(b)
129
+ const first = pending(),
130
+ second = pending()
131
+
132
+ a.play = vi.fn(() => first.promise)
133
+ b.play = vi.fn(() => second.promise)
134
+ const result = parent.run('show')
135
+ first.resolve('finished')
136
+ await Promise.resolve()
137
+ expect(parent.play).not.toHaveBeenCalled()
138
+ second.resolve('finished')
139
+ expect(await result).toBe('finished')
140
+ expect(parent.play).toHaveBeenCalledOnce()
141
+ })
142
+
143
+ it('does not launch an obsolete child phase after replacement', async () => {
144
+ const parent = node({ when: 'beforeChildren' }),
145
+ child = node()
146
+
147
+ parent.register(child)
148
+ const old = pending()
149
+ parent.play = vi.fn(() => old.promise)
150
+ const obsolete = parent.run('show')
151
+ parent.play = vi.fn(async () => 'finished')
152
+ await parent.run('hide')
153
+ old.resolve('finished')
154
+ expect(await obsolete).toBe('cancelled')
155
+ expect(child.play).toHaveBeenCalledTimes(1)
156
+ expect(child.play).toHaveBeenCalledWith('hide', { x: 10 }, { delay: 0 })
157
+ })
158
+
159
+ it('animates late children after the current parent run finishes', async () => {
160
+ const parent = node({ when: 'beforeChildren', delayChildren: 0.1 }),
161
+ child = node()
162
+
163
+ await parent.run('show')
164
+ parent.register(child)
165
+ await Promise.resolve()
166
+ expect(child.play).toHaveBeenCalledWith('show', { x: 10 }, { delay: 0.1 })
167
+ })
168
+
169
+ it('keeps independently started exits alive while invalidating queued tree work', async () => {
170
+ const parent = node({ when: 'beforeChildren' }),
171
+ child = node()
172
+
173
+ parent.register(child)
174
+ const job = pending()
175
+ parent.play = vi.fn(() => job.promise)
176
+ const run = parent.run('show')
177
+ const stops = (child.stopOwn as ReturnType<typeof vi.fn>).mock.calls.length
178
+ parent.cancelPending()
179
+ job.resolve('finished')
180
+ expect(await run).toBe('cancelled')
181
+ expect(child.stopOwn).toHaveBeenCalledTimes(stops)
182
+ expect(child.play).not.toHaveBeenCalled()
183
+ })
184
+
185
+ it('does not count a late child twice while waiting for the parent phase', async () => {
186
+ const parent = node({ when: 'beforeChildren' }),
187
+ child = node()
188
+
189
+ const job = pending()
190
+ parent.play = vi.fn(() => job.promise)
191
+ const run = parent.run('show')
192
+ parent.register(child)
193
+ job.resolve('finished')
194
+ await run
195
+ await Promise.resolve()
196
+ expect(child.play).toHaveBeenCalledOnce()
197
+ })