|
| 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