Skip to content

Commit 79e0b05

Browse files
committed
test: add smoke coverage for every effect component
Mounts each effect with minimal props inside a real EffectComposer and checks it doesn't crash and disposes correctly; a coverage test fails CI if a new file under src/effects isn't added to the manifest or the exclusion list, so this can't silently go stale as effects are added.
1 parent 9ff4905 commit 79e0b05

1 file changed

Lines changed: 234 additions & 0 deletions

File tree

src/tests/effects.smoke.test.tsx

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
// Generic smoke coverage for every effect component: mounts inside a real
2+
// EffectComposer with the minimum props each one actually requires, confirms
3+
// mounting/unmounting doesn't throw, and that dispose() gets called exactly
4+
// once per instance on unmount (for both <primitive>-based effects, via our
5+
// useDispose hook, and wrapEffect-based ones, via r3f's own auto-dispose).
6+
//
7+
// This catches constructor/prop-application/dispose-time crashes — it does
8+
// NOT verify visual/shader correctness. The test environment's WebGL context
9+
// is a Proxy of no-ops (see test-utils.tsx), so nothing actually renders;
10+
// effects still need manual/visual verification before release.
11+
//
12+
// Coverage is enforced by the last test in this file: every *.tsx file in
13+
// src/effects must appear either in SMOKE_CASES or EXCLUDED below. Adding a
14+
// new effect file without touching either list fails CI.
15+
//
16+
// This file is excluded from `tsc -p tsconfig.json` (it matches
17+
// src/**/*.test.*), so editors fall back to a detached/inferred compilation
18+
// context for it that doesn't automatically pick up @types/node — hence the
19+
// explicit reference below for the node: imports used by the coverage check.
20+
21+
/// <reference types="node" />
22+
23+
import fs from 'node:fs'
24+
import path from 'node:path'
25+
import { fileURLToPath } from 'node:url'
26+
import { CopyPass, DepthPickingPass, EffectComposer as EffectComposerImpl } from 'postprocessing'
27+
import * as React from 'react'
28+
import * as THREE from 'three'
29+
import { describe, expect, it, vi } from 'vitest'
30+
import { EffectComposer } from '../EffectComposer'
31+
import { Autofocus } from '../effects/Autofocus'
32+
import { Bloom } from '../effects/Bloom'
33+
import { BrightnessContrast } from '../effects/BrightnessContrast'
34+
import { ChromaticAberration } from '../effects/ChromaticAberration'
35+
import { ColorAverage } from '../effects/ColorAverage'
36+
import { ColorDepth } from '../effects/ColorDepth'
37+
import { Depth } from '../effects/Depth'
38+
import { DepthOfField } from '../effects/DepthOfField'
39+
import { DotScreen } from '../effects/DotScreen'
40+
import { FXAA } from '../effects/FXAA'
41+
import { Glitch } from '../effects/Glitch'
42+
import { GodRays } from '../effects/GodRays'
43+
import { Grid } from '../effects/Grid'
44+
import { HueSaturation } from '../effects/HueSaturation'
45+
import { LensFlare } from '../effects/LensFlare'
46+
import { LUT } from '../effects/LUT'
47+
import { N8AO } from '../effects/N8AO'
48+
import { Noise } from '../effects/Noise'
49+
import { Outline } from '../effects/Outline'
50+
import { Pixelation } from '../effects/Pixelation'
51+
import { Ramp } from '../effects/Ramp'
52+
import { Scanline } from '../effects/ScanlineEffect'
53+
import { SelectiveBloom } from '../effects/SelectiveBloom'
54+
import { Sepia } from '../effects/Sepia'
55+
import { ShockWave } from '../effects/ShockWave'
56+
import { SMAA } from '../effects/SMAA'
57+
import { SSAO } from '../effects/SSAO'
58+
import { TiltShift } from '../effects/TiltShift'
59+
import { TiltShift2 } from '../effects/TiltShift2'
60+
import { ToneMapping } from '../effects/ToneMapping'
61+
import { Vignette } from '../effects/Vignette'
62+
import { WaterEffect } from '../effects/Water'
63+
import { flush, root } from './test-utils'
64+
65+
type SmokeCase = {
66+
/** Filename under src/effects this case covers — drives the coverage check below. */
67+
file: string
68+
label: string
69+
composerProps?: Record<string, unknown>
70+
/** Extra scene content the effect needs (e.g. a sun mesh for GodRays). */
71+
extras?: React.ReactNode
72+
/** Renders the effect element. `ref` may be ignored by effects that don't forward one (e.g. LensFlare). */
73+
effect: (ref: React.Ref<any>) => React.ReactElement
74+
}
75+
76+
const sunMesh = new THREE.Mesh(new THREE.SphereGeometry(1, 8, 8))
77+
const lutTexture = new THREE.DataTexture(new Uint8Array(4 * 4 * 4 * 4), 4, 4)
78+
79+
const SMOKE_CASES: SmokeCase[] = [
80+
{ file: 'Autofocus.tsx', label: 'Autofocus', effect: (ref) => <Autofocus ref={ref} /> },
81+
{ file: 'Bloom.tsx', label: 'Bloom', effect: (ref) => <Bloom ref={ref} /> },
82+
{ file: 'BrightnessContrast.tsx', label: 'BrightnessContrast', effect: (ref) => <BrightnessContrast ref={ref} /> },
83+
{ file: 'ChromaticAberration.tsx', label: 'ChromaticAberration', effect: (ref) => <ChromaticAberration ref={ref} /> },
84+
{ file: 'ColorAverage.tsx', label: 'ColorAverage', effect: (ref) => <ColorAverage ref={ref} /> },
85+
{ file: 'ColorDepth.tsx', label: 'ColorDepth', effect: (ref) => <ColorDepth ref={ref} /> },
86+
{ file: 'Depth.tsx', label: 'Depth', effect: (ref) => <Depth ref={ref} /> },
87+
{ file: 'DepthOfField.tsx', label: 'DepthOfField', effect: (ref) => <DepthOfField ref={ref} /> },
88+
{ file: 'DotScreen.tsx', label: 'DotScreen', effect: (ref) => <DotScreen ref={ref} /> },
89+
{ file: 'FXAA.tsx', label: 'FXAA', effect: (ref) => <FXAA ref={ref} /> },
90+
{ file: 'Glitch.tsx', label: 'Glitch', effect: (ref) => <Glitch ref={ref} /> },
91+
{
92+
file: 'GodRays.tsx',
93+
label: 'GodRays',
94+
extras: <primitive object={sunMesh} />,
95+
effect: (ref) => <GodRays ref={ref} sun={sunMesh} />,
96+
},
97+
{ file: 'Grid.tsx', label: 'Grid', effect: (ref) => <Grid ref={ref} /> },
98+
{ file: 'HueSaturation.tsx', label: 'HueSaturation', effect: (ref) => <HueSaturation ref={ref} /> },
99+
// LensFlare manages its own internal ref and doesn't accept one as a prop.
100+
{ file: 'LensFlare.tsx', label: 'LensFlare', effect: () => <LensFlare /> },
101+
{ file: 'LUT.tsx', label: 'LUT', effect: (ref) => <LUT ref={ref} lut={lutTexture} /> },
102+
{ file: 'N8AO.tsx', label: 'N8AO', effect: (ref) => <N8AO ref={ref} /> },
103+
{ file: 'Noise.tsx', label: 'Noise', effect: (ref) => <Noise ref={ref} /> },
104+
{ file: 'Outline.tsx', label: 'Outline', effect: (ref) => <Outline ref={ref} /> },
105+
{ file: 'Pixelation.tsx', label: 'Pixelation', effect: (ref) => <Pixelation ref={ref} /> },
106+
{ file: 'Ramp.tsx', label: 'Ramp', effect: (ref) => <Ramp ref={ref} /> },
107+
{ file: 'ScanlineEffect.tsx', label: 'Scanline', effect: (ref) => <Scanline ref={ref} /> },
108+
{
109+
file: 'SelectiveBloom.tsx',
110+
label: 'SelectiveBloom',
111+
effect: (ref) => <SelectiveBloom ref={ref} lights={[]} />,
112+
},
113+
{ file: 'Sepia.tsx', label: 'Sepia', effect: (ref) => <Sepia ref={ref} /> },
114+
{ file: 'ShockWave.tsx', label: 'ShockWave', effect: (ref) => <ShockWave ref={ref} /> },
115+
{ file: 'SMAA.tsx', label: 'SMAA', effect: (ref) => <SMAA ref={ref} /> },
116+
{
117+
file: 'SSAO.tsx',
118+
label: 'SSAO (without normal pass — sentinel path)',
119+
effect: (ref) => <SSAO ref={ref} />,
120+
},
121+
{
122+
file: 'SSAO.tsx',
123+
label: 'SSAO (with normal pass — real construction path)',
124+
composerProps: { enableNormalPass: true },
125+
effect: (ref) => <SSAO ref={ref} />,
126+
},
127+
{ file: 'TiltShift.tsx', label: 'TiltShift', effect: (ref) => <TiltShift ref={ref} /> },
128+
{ file: 'TiltShift2.tsx', label: 'TiltShift2', effect: (ref) => <TiltShift2 ref={ref} /> },
129+
{ file: 'ToneMapping.tsx', label: 'ToneMapping', effect: (ref) => <ToneMapping ref={ref} /> },
130+
{ file: 'Vignette.tsx', label: 'Vignette', effect: (ref) => <Vignette ref={ref} /> },
131+
{ file: 'Water.tsx', label: 'WaterEffect', effect: (ref) => <WaterEffect ref={ref} /> },
132+
]
133+
134+
const EXCLUDED: Record<string, string> = {
135+
'Texture.tsx':
136+
'loads via useLoader(TextureLoader, textureSrc) — needs a real image decode pipeline this test environment cannot provide. Verify manually.',
137+
'ASCII.tsx':
138+
"constructs its character atlas via document.createElement('canvas') — this project's vitest config runs the node test environment (no jsdom/document). Verify manually.",
139+
}
140+
141+
describe('effect smoke tests', () => {
142+
it.each(SMOKE_CASES)('$label mounts and unmounts without throwing', async ({ composerProps, extras, effect }) => {
143+
const ref = React.createRef<any>()
144+
const composerRef = React.createRef<EffectComposerImpl>()
145+
146+
await React.act(async () =>
147+
root.render(
148+
<EffectComposer ref={composerRef} {...composerProps}>
149+
{extras}
150+
{effect(ref)}
151+
</EffectComposer>
152+
)
153+
)
154+
155+
await flush()
156+
157+
const instance = ref.current
158+
const disposeSpy = instance && typeof instance.dispose === 'function' ? vi.spyOn(instance, 'dispose') : null
159+
160+
await React.act(async () => root.render(null))
161+
await flush()
162+
163+
if (disposeSpy) {
164+
expect(disposeSpy).toHaveBeenCalledTimes(1)
165+
}
166+
})
167+
168+
// Tracks dispose() calls per instance rather than per class — EffectComposerImpl
169+
// constructs its own internal CopyPass (this.copyPass, for compositing) and
170+
// disposes it as part of its own teardown, unrelated to any CopyPass an effect
171+
// constructs. A class-wide spy would conflate the two into a false "double
172+
// dispose"; this only flags it if the *same* instance is disposed twice.
173+
function trackDisposePerInstance(Ctor: { prototype: { dispose: (...args: unknown[]) => unknown } }) {
174+
const counts = new Map<object, number>()
175+
const original = Ctor.prototype.dispose
176+
const spy = vi.spyOn(Ctor.prototype, 'dispose').mockImplementation(function (this: object, ...args: unknown[]) {
177+
counts.set(this, (counts.get(this) ?? 0) + 1)
178+
return original.apply(this, args)
179+
})
180+
return {
181+
restore: () => spy.mockRestore(),
182+
maxCallsForAnySingleInstance: () => Math.max(0, ...counts.values()),
183+
}
184+
}
185+
186+
// Autofocus's ref resolves to { dofRef, hitpoint, update } (its own
187+
// imperative API), not an effect instance — the generic dispose check
188+
// above silently no-ops for it. It actually owns three disposables
189+
// (depthPickingPass, copyPass, and the DepthOfField effect it renders
190+
// internally), verified explicitly here instead.
191+
it('Autofocus disposes depthPickingPass, copyPass, and the nested DepthOfField effect exactly once each', async () => {
192+
const depthPickingTracker = trackDisposePerInstance(DepthPickingPass)
193+
const copyPassTracker = trackDisposePerInstance(CopyPass)
194+
// AutofocusProps' `ref` type is broken (ComponentProps<typeof DepthOfField>
195+
// drags in DepthOfField's own `ref: Ref<DepthOfFieldEffect>`, which then
196+
// intersects with `Ref<AutofocusApi>` — separate pre-existing issue,
197+
// not fixed here). `any` sidesteps it; the runtime shape is AutofocusApi.
198+
const ref = React.createRef<any>()
199+
200+
await React.act(async () =>
201+
root.render(
202+
<EffectComposer>
203+
<Autofocus ref={ref} />
204+
</EffectComposer>
205+
)
206+
)
207+
208+
await flush()
209+
210+
const dofEffect = ref.current!.dofRef.current
211+
expect(dofEffect).toBeTruthy()
212+
const dofDisposeSpy = vi.spyOn(dofEffect!, 'dispose')
213+
214+
await React.act(async () => root.render(null))
215+
await flush()
216+
217+
expect(depthPickingTracker.maxCallsForAnySingleInstance()).toBeLessThanOrEqual(1)
218+
expect(copyPassTracker.maxCallsForAnySingleInstance()).toBeLessThanOrEqual(1)
219+
expect(dofDisposeSpy).toHaveBeenCalledTimes(1)
220+
221+
depthPickingTracker.restore()
222+
copyPassTracker.restore()
223+
})
224+
225+
it('covers every file in src/effects (or documents why it is excluded)', () => {
226+
const effectsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'effects')
227+
const files = fs.readdirSync(effectsDir).filter((f) => f.endsWith('.tsx'))
228+
229+
const covered = new Set(SMOKE_CASES.map((c) => c.file))
230+
const missing = files.filter((f) => !covered.has(f) && !(f in EXCLUDED))
231+
232+
expect(missing).toEqual([])
233+
})
234+
})

0 commit comments

Comments
 (0)