feat: barcode scanner hook (BarcodeDetector + zxing fallback) and camera component
This commit is contained in:
88
web/test/scanner.test.ts
Normal file
88
web/test/scanner.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { useBarcodeScanner, shouldEmit } from '../src/hooks/useBarcodeScanner.js'
|
||||
|
||||
function fakeStream(): MediaStream {
|
||||
const tracks: MediaStreamTrack[] = [{ stop: vi.fn() } as unknown as MediaStreamTrack]
|
||||
return { getTracks: () => tracks } as unknown as MediaStream
|
||||
}
|
||||
|
||||
function stubMediaDevices(impl: () => Promise<MediaStream>) {
|
||||
vi.stubGlobal(
|
||||
'navigator',
|
||||
Object.assign(Object.create(Object.getPrototypeOf(navigator)), navigator, {
|
||||
mediaDevices: { getUserMedia: impl },
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('shouldEmit (cooldown dedup)', () => {
|
||||
it('emits a new code, suppresses a rapid repeat, emits after cooldown', () => {
|
||||
expect(shouldEmit(null, '123', 0, 1500)).toBe(true)
|
||||
expect(shouldEmit({ code: '123', at: 100 }, '123', 500, 1500)).toBe(false)
|
||||
expect(shouldEmit({ code: '123', at: 100 }, '456', 500, 1500)).toBe(true)
|
||||
expect(shouldEmit({ code: '123', at: 100 }, '123', 1700, 1500)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBarcodeScanner', () => {
|
||||
it('detects a barcode via BarcodeDetector and calls onDetect', async () => {
|
||||
const detections = [[], [{ rawValue: '5021592210629', format: 'ean_13' }]]
|
||||
let call = 0
|
||||
class FakeDetector {
|
||||
constructor(_opts: { formats: string[] }) {
|
||||
expect(_opts.formats).toEqual(['ean_13', 'upc_a', 'ean_8'])
|
||||
}
|
||||
async detect() {
|
||||
return detections[Math.min(call++, 1)]!
|
||||
}
|
||||
}
|
||||
;(window as unknown as { BarcodeDetector?: unknown }).BarcodeDetector = FakeDetector
|
||||
stubMediaDevices(() => Promise.resolve(fakeStream()))
|
||||
|
||||
const onDetect = vi.fn()
|
||||
const videoRef = { current: document.createElement('video') } as React.RefObject<HTMLVideoElement>
|
||||
const { result } = renderHook(() => useBarcodeScanner(videoRef, onDetect, true))
|
||||
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'))
|
||||
await waitFor(() => expect(onDetect).toHaveBeenCalledWith('5021592210629'))
|
||||
})
|
||||
|
||||
it('reports denied when camera permission is rejected', async () => {
|
||||
stubMediaDevices(() =>
|
||||
Promise.reject(new DOMException('denied', 'NotAllowedError'))
|
||||
)
|
||||
const videoRef = { current: document.createElement('video') } as React.RefObject<HTMLVideoElement>
|
||||
const { result } = renderHook(() => useBarcodeScanner(videoRef, () => {}, true))
|
||||
await waitFor(() => expect(result.current.status).toBe('denied'))
|
||||
})
|
||||
|
||||
it('stops the stream when enabled goes false', async () => {
|
||||
const stream = fakeStream()
|
||||
const stopSpy = vi.spyOn(stream.getTracks()[0]!, 'stop')
|
||||
stubMediaDevices(() => Promise.resolve(stream))
|
||||
;(window as unknown as { BarcodeDetector?: unknown }).BarcodeDetector = class {
|
||||
async detect() {
|
||||
return []
|
||||
}
|
||||
}
|
||||
const videoRef = { current: document.createElement('video') } as React.RefObject<HTMLVideoElement>
|
||||
const { result, rerender } = renderHook(
|
||||
({ enabled }) => useBarcodeScanner(videoRef, () => {}, enabled),
|
||||
{ initialProps: { enabled: true } }
|
||||
)
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'))
|
||||
rerender({ enabled: false })
|
||||
expect(stopSpy).toHaveBeenCalled()
|
||||
expect(result.current.status).toBe('idle')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user