Skip to content

Commit 818d7e6

Browse files
authored
chore: Test coordinates system bug in #3878 (#3896)
* chore: Test coordinates system bug in #3878 * Update visioncamera.coordinates.harness.tsx * Update visioncamera.coordinates.harness.tsx * fix: Use `toBeCloseTo` over manual inline math * notes
1 parent c38f739 commit 818d7e6

2 files changed

Lines changed: 162 additions & 0 deletions

File tree

apps/simple-camera/__tests__/README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,12 @@ Assert things that require the camera to actually do work:
153153
`mirrorMode: 'auto'`, the resulting `Photo.isMirrored` reflects the device's
154154
front/back position. These cross-field invariants are exactly what types
155155
can't catch and what real bugs ship as.
156+
- **Approximate numeric values use matcher tolerances** — for coordinates,
157+
dimensions, timestamps, or similar floating-point values, prefer
158+
`expect(actual).toBeCloseTo(expected, digits)` over manual
159+
`Math.abs(actual - expected)` assertions. The matcher is shorter, keeps the
160+
expected value visible in failures, and matches the style used throughout
161+
the coordinate tests.
156162
- **Lifecycle and listeners fire in the right order**
157163
`addOnStartedListener` resolves after `start()`, `addOnStoppedListener`
158164
after `stop()`, recording callbacks after `recorder.stop()`. Wait on the
@@ -201,6 +207,28 @@ camera`, `getSupportedVideoCodecs`, etc.) and Android-only features
201207
guard. Do not branch on `Platform.OS` to mask behavioral differences that
202208
should be identical across platforms — flag those as bugs.
203209

210+
### 9. Keep assertions compact and diagnostic
211+
212+
Tests should read like a small executable spec for one behavior. A few patterns
213+
keep them simpler without making them weaker:
214+
215+
- **Name the invariant, not the implementation detail.** Prefer local names
216+
like `expectedBounds`, `reportedBounds`, `roundTripped`, or `capturedPhoto`
217+
over names that describe temporary mechanics.
218+
- **Use matcher assertions instead of boolean arithmetic.** Prefer
219+
`toBeCloseTo`, `toHaveLength`, `toContain`, `toEqual`, and
220+
`rejects.toThrow` over manually computing booleans and asserting on those.
221+
- **Loop over repeated dimensions or cases.** For edges, axes, formats, or
222+
corner points, a tiny inline array plus one expectation is clearer than four
223+
copy-pasted assertions that can drift.
224+
- **Keep local math behind local names.** Small functions inside an `it` block
225+
are fine when they name a one-off transform or assertion, such as
226+
`getBounds(...)`. Do not extract shared setup helpers; the camera session
227+
still needs to be built inline.
228+
- **Log the facts needed to debug a CI failure.** One final `console.log`
229+
with orientation, resolution, or compared values is useful. Per-frame or
230+
per-step logs usually make Harness output harder to read.
231+
204232
---
205233

206234
## Running the tests

apps/simple-camera/__tests__/visioncamera.coordinates.harness.tsx

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,140 @@ describe('VisionCamera - Coordinates', () => {
530530
}
531531
})
532532

533+
// Analyzer coordinates may be reported in the Frame's intended/oriented
534+
// image space, while Frame.convertFramePointToCameraPoint consumes raw
535+
// buffer-space points. The center-only test above cannot catch an
536+
// off-center rectangle drifting after orientation is applied.
537+
// See https://github.com/mrousavy/react-native-vision-camera/pull/3878.
538+
it('maps oriented Frame rectangles into the same Camera bounds', async () => {
539+
const session = await VisionCamera.createCameraSession(false)
540+
const frameOutput = VisionCamera.createFrameOutput({
541+
targetResolution: CommonResolutions.HD_16_9,
542+
pixelFormat: 'yuv',
543+
enablePreviewSizedOutputBuffers: false,
544+
enablePhysicalBufferRotation: false,
545+
enableCameraMatrixDelivery: false,
546+
allowDeferredStart: false,
547+
dropFramesWhileBusy: true,
548+
})
549+
await session.configure([
550+
{
551+
input: backDevice,
552+
outputs: [{ output: frameOutput, mirrorMode: 'auto' }],
553+
constraints: [],
554+
},
555+
])
556+
557+
type Bounds = {
558+
left: number
559+
top: number
560+
right: number
561+
bottom: number
562+
}
563+
type ProjectionReport = {
564+
orientation: string
565+
expected: Bounds
566+
reported: Bounds
567+
}
568+
let report: ProjectionReport | undefined
569+
const onReport = (r: ProjectionReport) => {
570+
report = r
571+
}
572+
573+
let sessionError: Error | undefined
574+
const errorSub = session.addOnErrorListener((error) => {
575+
sessionError = error
576+
})
577+
578+
const runtime = workletsProvider.createRuntimeForThread(frameOutput.thread)
579+
runtime.setOnFrameCallback(frameOutput, (frame) => {
580+
'worklet'
581+
const w = frame.width
582+
const h = frame.height
583+
584+
const orientedWidth =
585+
frame.orientation === 'left' || frame.orientation === 'right' ? h : w
586+
const orientedHeight =
587+
frame.orientation === 'left' || frame.orientation === 'right' ? w : h
588+
const box = {
589+
left: orientedWidth * 0.34,
590+
top: orientedHeight * 0.29,
591+
right: orientedWidth * 0.62,
592+
bottom: orientedHeight * 0.57,
593+
}
594+
const orientedCorners: Point[] = [
595+
{ x: box.left, y: box.top },
596+
{ x: box.right, y: box.top },
597+
{ x: box.right, y: box.bottom },
598+
{ x: box.left, y: box.bottom },
599+
]
600+
601+
const orientedPointToFramePoint = (point: Point): Point => {
602+
switch (frame.orientation) {
603+
case 'right':
604+
return { x: w - point.y, y: point.x }
605+
case 'left':
606+
return { x: point.y, y: h - point.x }
607+
case 'down':
608+
return { x: w - point.x, y: h - point.y }
609+
default:
610+
return point
611+
}
612+
}
613+
const getCameraBounds = (points: Point[]): Bounds => {
614+
const cameraPoints = points.map((point) =>
615+
frame.convertFramePointToCameraPoint(point),
616+
)
617+
const xs = cameraPoints.map((point) => point.x)
618+
const ys = cameraPoints.map((point) => point.y)
619+
return {
620+
left: Math.min(...xs),
621+
top: Math.min(...ys),
622+
right: Math.max(...xs),
623+
bottom: Math.max(...ys),
624+
}
625+
}
626+
627+
scheduleOnRN(onReport, {
628+
orientation: frame.orientation,
629+
expected: getCameraBounds(
630+
orientedCorners.map(orientedPointToFramePoint),
631+
),
632+
reported: getCameraBounds(orientedCorners),
633+
})
634+
frame.dispose()
635+
})
636+
637+
await session.start()
638+
try {
639+
await waitUntil(() => report != null || sessionError != null, {
640+
timeout: 15_000,
641+
})
642+
expect(sessionError).toBe(undefined)
643+
const r = report
644+
if (r == null) throw new Error('no rectangle projection report')
645+
646+
if (r.orientation === 'up') {
647+
console.log(
648+
'[SKIP] oriented rectangle projection: frame orientation is up',
649+
)
650+
return
651+
}
652+
653+
for (const edge of ['left', 'top', 'right', 'bottom'] as const) {
654+
expect(r.reported[edge]).toBeCloseTo(r.expected[edge], 0)
655+
}
656+
657+
console.log(
658+
`oriented rectangle projection orientation=${r.orientation} expected=${JSON.stringify(r.expected)} reported=${JSON.stringify(r.reported)}`,
659+
)
660+
} finally {
661+
runtime.setOnFrameCallback(frameOutput, undefined)
662+
errorSub.remove()
663+
await session.stop()
664+
}
665+
})
666+
533667
// TODO: Re-enable once we have a way to produce a ScannedObject without a
534668
// real on-device scan (e.g. a `createMockScannedObject` factory or
535669
// a CI-friendly QR fixture). On iOS, the only way to obtain a

0 commit comments

Comments
 (0)