1
0

feat: barcode scanner hook (BarcodeDetector + zxing fallback) and camera component

This commit is contained in:
2026-08-29 22:11:01 +02:00
parent bda43ce237
commit 70b7fdbe1b
6 changed files with 282 additions and 1 deletions

88
web/test/scanner.test.ts Normal file
View 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')
})
})