Skip to content

Commit

Permalink
Merge pull request #14 from threlte/invalidation
Browse files Browse the repository at this point in the history
Return frameInvalidated from advance function
  • Loading branch information
michealparks authored Jun 7, 2024
2 parents 4968c61 + f4f821a commit 4102a01
Show file tree
Hide file tree
Showing 11 changed files with 149 additions and 29 deletions.
5 changes: 5 additions & 0 deletions .changeset/eighty-boxes-wonder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@threlte/test': patch
---

Return frameInvalidated from advance function
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,19 @@ const {

### Advance

In the test renderer environment, Threlte's render mode is set to `manual`. `advance` is very similar to the function of the same name returned by the `useThrelte` hook, but it advances at a fixed rate (16ms) regardless of environment. The number of times called and delta can also be configured when calling it.
In the test renderer environment, Threlte's render mode is set to `manual`. If you wish to test results produced by running `useTask`, you must call `advance`. `advance` is very similar to the function of the same name returned by the `useThrelte` hook, but it advances at a fixed rate (16ms) regardless of environment. The number of times called and delta can also be configured when calling it.

```ts
// Runs advance() 10 times with a 33.3ms delta
advance({ delta: 33.3, count: 10 })
```

`advance` will also return a flag indicating whether calling it resulted in a frame invalidation.

```ts
const { frameInvalidated } = advance()
```

### fireEvent

If your component uses the `interactivity` plugin, you can test events using the `fireEvent` function. Let's say we have a component like this:
Expand Down
25 changes: 19 additions & 6 deletions src/lib/Container.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { ACESFilmicToneMapping } from 'three'
import { interactivity } from '@threlte/extras'
import { mockAdvanceFn } from './advance'
import { getContext } from 'svelte'
import { writable } from 'svelte/store'
/** @type {HTMLCanvasElement} */
Expand All @@ -19,6 +20,14 @@
/** @type {{ height: number, width: number }} */
export let userSize = { height: 720, width: 1280 }
if (!globalThis.ResizeObserver) {
globalThis.ResizeObserver = class {
observe = () => {}
unobserve = () => {}
disconnect = () => {}
}
}
export const threlteContext = createThrelteContext({
autoRender: true,
colorManagementEnabled: true,
Expand All @@ -32,6 +41,14 @@
userSize: writable(userSize),
})
/** @type {{
* dispose: () => void,
* frameInvalidated: boolean,
* resetFrameInvalidation: () => void
* }}
*/
export const internalContext = getContext('threlte-internal-context')
/**
* We aren't interested as of now in providing a full mock.
*
Expand All @@ -40,13 +57,9 @@
const rendererMock = { domElement: canvas }
threlteContext.renderer = rendererMock
mockAdvanceFn(threlteContext)
mockAdvanceFn(threlteContext, internalContext)
export const interactivityContext = interactivity({
compute: () => {
return undefined
},
})
export const interactivityContext = interactivity()
</script>

<SceneGraphObject object={threlteContext.scene}>
Expand Down
14 changes: 8 additions & 6 deletions src/lib/advance.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { getContext } from 'svelte'

/**
* @typedef {{
* count?: number,
Expand All @@ -10,22 +8,22 @@ import { getContext } from 'svelte'
/**
* @typedef {{
* dispose: () => void,
* frameInvalidated: boolean,
* resetFrameInvalidation: () => void
* }} InternalCtx
*/

/**
*
* @param context {import('@threlte/core').ThrelteContext}
* @param internalContext {InternalCtx}
* @returns {undefined}
*/
export const mockAdvanceFn = (context) => {
/** @type InternalCtx */
const internalContext = getContext('threlte-internal-context')

export const mockAdvanceFn = (context, internalContext) => {
/**
*
* @param {AdvanceOptions} options
* @returns {{ frameInvalidated: boolean }}
*/
context.advance = (options = {}) => {
internalContext.dispose()
Expand All @@ -38,5 +36,9 @@ export const mockAdvanceFn = (context) => {
}

internalContext.resetFrameInvalidation()

return {
frameInvalidated: internalContext.frameInvalidated,
}
}
}
17 changes: 11 additions & 6 deletions src/lib/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

import * as Svelte from 'svelte'
import * as THREE from 'three'
import type { ThrelteContext } from '@threlte/core'
import type { IntersectionEvent } from '@threlte/extras'
import type { CurrentWritable, ThrelteContext } from '@threlte/core'
import type { IntersectionEvent, interactivity } from '@threlte/extras'

export { act, cleanup, render } from './pure'

/** @TODO export from @threlte/extras */

type ThrelteEvents =
| 'click'
| 'contextmenu'
Expand All @@ -27,26 +29,29 @@ export function cleanup(): void

export function render(
component: typeof Svelte.SvelteComponent<any, any, any>,
componentOptions?: { target: HTMLElement } & Record<string, unknown>,
componentOptions?: { target?: HTMLElement } & Record<string, unknown>,
renderOptions?: {
baseElement?: HTMLElement
canvas?: HTMLCanvasElement
userSize?: { width: number; height: number }
}
): {
baseElement: HTMLElement
camera: THREE.PerspectiveCamera | THREE.OrthographicCamera
camera: CurrentWritable<THREE.PerspectiveCamera | THREE.OrthographicCamera>
component: Svelte.SvelteComponent<any, any, any>
container: HTMLElement
context: ThrelteContext
scene: THREE.Scene
// @TODO export from @threlte/extras
interactivity: ReturnType<typeof interactivity>
frameInvalidated: boolean

advance: (options?: { count?: number; delta?: number }) => void
advance: (options?: { count?: number; delta?: number }) => { frameInvalidated: boolean }

fireEvent(
object3D: THREE.Object3D,
event: ThrelteEvents,
payload: IntersectionEvent<ThrelteEvents>
payload?: IntersectionEvent<ThrelteEvents>
): Promise<void>

rerender(props: Record<string, unknown>): Promise<void>
Expand Down
14 changes: 12 additions & 2 deletions src/lib/pure.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,20 +125,30 @@ export const render = (Component, componentOptions = {}, renderOptions = {}) =>
*/
const context = component.threlteContext

const interactivity = component.$$
/**
* @type {{
* dispose: () => void,
* frameInvalidated: boolean,
* resetFrameInvalidation: () => void
* }}
*/
const internalCtx = component.internalContext

const handlerCtx = component.$$
? [...component.$$.context.values()].find((ctx) => {
return ctx.dispatchers || ctx.handlers
})
: component.interactivityContext

const handlers = interactivity.dispatchers || interactivity.handlers
const handlers = handlerCtx.dispatchers || handlerCtx.handlers

return {
baseElement,
camera: context.camera,
component: component.ref,
container: target,
context,
frameInvalidated: internalCtx.frameInvalidated,
scene: context.scene,

advance: context.advance,
Expand Down
21 changes: 21 additions & 0 deletions src/routes/Interactive.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<script lang="ts">
import { T } from '@threlte/core'
import { interactivity } from '@threlte/extras'
import type { Vector3Tuple } from 'three'
let positions: Vector3Tuple[] = [[0, 0, -1]]
const context = interactivity()
context.raycaster.near = 1
context.raycaster.far = 10
const spawnCube = () => {
positions = [...positions, [0, 0, positions.at(-1)?.[2] ?? 0 - 1]]
}
</script>

{#each positions as position}
<T.Mesh on:click={() => spawnCube()} scale={0.2} {position}>
<T.BoxGeometry />
</T.Mesh>
{/each}
22 changes: 22 additions & 0 deletions src/routes/Invalidate.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<script>
import { useTask, useThrelte } from '@threlte/core'
export let autoStart = false
export let autoInvalidate = true
export let prop1 = 0
export let prop2 = 0
const { invalidate } = useThrelte()
useTask(() => {}, { autoStart, autoInvalidate })
$: {
prop1
}
$: {
prop2
invalidate()
}
</script>
2 changes: 1 addition & 1 deletion src/routes/Scene.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
<T.DirectionalLight />
<T.AmbientLight />

<T.Mesh bind:ref on:click={onClick} position.x={positionX}>
<T.Mesh scale={0.5} bind:ref on:click={onClick} position.x={positionX}>
<T.MeshStandardMaterial />
<T.BoxGeometry />
</T.Mesh>
36 changes: 36 additions & 0 deletions src/routes/__tests__/Invalidate.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'

import Subject from '../Invalidate.svelte'
import { render } from '../../lib'

describe('<Invalidate>', () => {
it('does not invalidate on a frozen useTask', () => {
const { advance } = render(Subject)

const { frameInvalidated } = advance()

expect(frameInvalidated).toBe(false)
})

it('invalidates on a running useTask', () => {
const { advance } = render(Subject, { autoStart: true })

const { frameInvalidated } = advance()

expect(frameInvalidated).toBe(false)
})

it('does not invalidate when autoInvalidate is false on a running useTask', () => {
const { advance } = render(Subject, { autoInvalidate: false, autoStart: true })

const { frameInvalidated } = advance()

expect(frameInvalidated).toBe(false)
})

// @TODO ongoing discussion
it.skip('does not invalidate when a prop change does not call invalidate()', () => {
const { frameInvalidated } = render(Subject, { prop1: 10 })
expect(frameInvalidated).toBe(false)
})
})
14 changes: 7 additions & 7 deletions src/routes/__tests__/Scene.spec.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { BoxGeometry, type Mesh, MeshStandardMaterial } from 'three'
import { describe, expect, it, vi } from 'vitest'

import Scene from '../Scene.svelte'
import Subject from '../Scene.svelte'
import { render } from '../../lib'

describe('Scene', () => {
it('creates an ambient and directional light', () => {
const { scene } = render(Scene)
const { scene } = render(Subject)

expect(scene.getObjectByProperty('isAmbientLight', true)).toBeDefined()
expect(scene.getObjectByProperty('isDirectionalLight', true)).toBeDefined()
})

it('creates a box mesh with a boxGeometry and meshStandardMaterial', () => {
const { scene } = render(Scene)
const { scene } = render(Subject)

const mesh = scene.getObjectByProperty('isMesh', true) as Mesh
expect(mesh).toBeDefined()
Expand All @@ -22,7 +22,7 @@ describe('Scene', () => {
})

it('updates position', async () => {
const { scene, rerender } = render(Scene, { positionX: 1 })
const { scene, rerender } = render(Subject, { positionX: 1 })

const mesh = scene.getObjectByProperty('isMesh', true) as Mesh
expect(mesh.position.x).toBe(1)
Expand All @@ -33,13 +33,13 @@ describe('Scene', () => {
})

it('creates a default perspective camera at position [1, 1, 1]', () => {
const { camera } = render(Scene)
const { camera } = render(Subject)

expect(camera.current.position.toArray()).toStrictEqual([1, 1, 1])
})

it('rotates the box mesh on the x and y axis by the frame delta on each frame', () => {
const { scene, advance } = render(Scene)
const { scene, advance } = render(Subject)

const mesh = scene.getObjectByProperty('isMesh', true) as Mesh
expect(mesh.rotation.x).toBe(0)
Expand All @@ -53,7 +53,7 @@ describe('Scene', () => {

it('calls the onClick callback when the box mesh is clicked', async () => {
const onClick = vi.fn()
const { scene, fireEvent } = render(Scene, { onClick })
const { scene, fireEvent } = render(Subject, { onClick })

const mesh = scene.getObjectByProperty('isMesh', true) as Mesh
await fireEvent(mesh, 'click')
Expand Down

0 comments on commit 4102a01

Please sign in to comment.