From 11a12dee3de2105b10b75914e6f6cb2087289d1c Mon Sep 17 00:00:00 2001 From: Cengiz Date: Sat, 9 May 2026 11:25:57 +0600 Subject: [PATCH 1/2] Added sensor_orientation property to CameraInfo --- addon/src/main/model/CameraInfo.gd | 9 ++ addon/src/{main/model => shared}/FrameInfo.gd | 0 addon/src/{main/model => shared}/FrameSize.gd | 0 .../plugin/nativecamera/model/CameraInfo.java | 8 + .../plugin/nativecamera/CameraInfoTest.java | 129 +++++++++++++--- common/config/plugin.properties | 2 +- demo/Main.gd | 9 +- demo/addons/GMPShared/FrameInfo.gd.uid | 1 + demo/addons/GMPShared/FrameSize.gd.uid | 1 + .../model/CameraInfo.gd.uid | 2 +- .../model/FeedRequest.gd.uid | 2 +- .../NativeCameraPlugin/model/FrameInfo.gd.uid | 1 - .../NativeCameraPlugin/model/FrameSize.gd.uid | 1 - demo/project.godot | 1 + docs/README.md | 2 + ios/src/NativeCamera.swift | 26 +++- ios/src/model/CameraInfo.swift | 17 +- ios/src/model/camera_info_wrapper.mm | 7 + ios/test/unit/NativeCameraTests.swift | 145 ++++++++++++++++++ ios/test/unit/WrapperTests.mm | 89 +++++++++-- 20 files changed, 407 insertions(+), 45 deletions(-) rename addon/src/{main/model => shared}/FrameInfo.gd (100%) rename addon/src/{main/model => shared}/FrameSize.gd (100%) create mode 100644 demo/addons/GMPShared/FrameInfo.gd.uid create mode 100644 demo/addons/GMPShared/FrameSize.gd.uid delete mode 100644 demo/addons/NativeCameraPlugin/model/FrameInfo.gd.uid delete mode 100644 demo/addons/NativeCameraPlugin/model/FrameSize.gd.uid diff --git a/addon/src/main/model/CameraInfo.gd b/addon/src/main/model/CameraInfo.gd index 6b9281e..7f17d20 100644 --- a/addon/src/main/model/CameraInfo.gd +++ b/addon/src/main/model/CameraInfo.gd @@ -7,6 +7,7 @@ class_name CameraInfo extends RefCounted const DATA_CAMERA_ID_PROPERTY := &"camera_id" const DATA_IS_FRONT_FACING_PROPERTY := &"is_front_facing" const DATA_OUTPUT_SIZES_PROPERTY := &"output_sizes" +const DATA_SENSOR_ORIENTATION_PROPERTY := &"sensor_orientation" var _data: Dictionary @@ -30,3 +31,11 @@ func get_output_sizes() -> Array[FrameSize]: __sizes.append(FrameSize.new(__size_dict)) return __sizes + + +## Returns the clockwise angle in degrees (0, 90, 180, or 270) that the camera +## sensor image must be rotated to be upright when the device is held in its +## natural (portrait) orientation. Defaults to [code]0[/code] when the value +## is unavailable. +func get_sensor_orientation() -> int: + return _data[DATA_SENSOR_ORIENTATION_PROPERTY] if _data.has(DATA_SENSOR_ORIENTATION_PROPERTY) else 0 diff --git a/addon/src/main/model/FrameInfo.gd b/addon/src/shared/FrameInfo.gd similarity index 100% rename from addon/src/main/model/FrameInfo.gd rename to addon/src/shared/FrameInfo.gd diff --git a/addon/src/main/model/FrameSize.gd b/addon/src/shared/FrameSize.gd similarity index 100% rename from addon/src/main/model/FrameSize.gd rename to addon/src/shared/FrameSize.gd diff --git a/android/src/main/java/org/godotengine/plugin/nativecamera/model/CameraInfo.java b/android/src/main/java/org/godotengine/plugin/nativecamera/model/CameraInfo.java index 17eae63..ae889f4 100644 --- a/android/src/main/java/org/godotengine/plugin/nativecamera/model/CameraInfo.java +++ b/android/src/main/java/org/godotengine/plugin/nativecamera/model/CameraInfo.java @@ -22,6 +22,7 @@ public class CameraInfo { private static final String DATA_CAMERA_ID_PROPERTY = "camera_id"; private static final String DATA_IS_FRONT_FACING_PROPERTY = "is_front_facing"; private static final String DATA_OUTPUT_SIZES_PROPERTY = "output_sizes"; + private static final String DATA_SENSOR_ORIENTATION_PROPERTY = "sensor_orientation"; private String cameraId; private CameraCharacteristics characteristics; @@ -56,6 +57,13 @@ public Dictionary buildRawData() { dict.put(DATA_OUTPUT_SIZES_PROPERTY, dictList.toArray()); + // SENSOR_ORIENTATION is the clockwise angle (0, 90, 180, or 270 degrees) that + // the camera image must be rotated to be upright when the device is held in its + // natural (portrait) orientation. Null-safe: defaults to 0 when the value is + // unavailable (e.g. on emulators or in unit tests). + Integer sensorOrientation = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION); + dict.put(DATA_SENSOR_ORIENTATION_PROPERTY, sensorOrientation != null ? sensorOrientation : 0); + return dict; } } diff --git a/android/src/test/java/org/godotengine/plugin/nativecamera/CameraInfoTest.java b/android/src/test/java/org/godotengine/plugin/nativecamera/CameraInfoTest.java index 625882f..48c130a 100644 --- a/android/src/test/java/org/godotengine/plugin/nativecamera/CameraInfoTest.java +++ b/android/src/test/java/org/godotengine/plugin/nativecamera/CameraInfoTest.java @@ -27,6 +27,14 @@ * Unit tests for {@link CameraInfo}. * *

All Android framework classes are mocked with Mockito — no real Android runtime is required. + * + *

{@link CameraCharacteristics#get} is called three times in {@link CameraInfo#buildRawData}: + *

    + *
  1. {@link CameraCharacteristics#LENS_FACING}
  2. + *
  3. {@link CameraCharacteristics#SCALER_STREAM_CONFIGURATION_MAP}
  4. + *
  5. {@link CameraCharacteristics#SENSOR_ORIENTATION}
  6. + *
+ * Each mock helper stubs these three return values in order via chained {@code thenReturn} calls. */ @ExtendWith(MockitoExtension.class) public class CameraInfoTest { @@ -40,8 +48,13 @@ private static Size size(int w, int h) { return s; } + /** + * Builds a {@link CameraCharacteristics} mock whose three sequential {@code get()} calls + * return {@code facing}, {@code map}, and {@code sensorOrientation} respectively. + */ @SuppressWarnings("unchecked") - private CameraCharacteristics mockCharacteristics(int facing, Size[] outputSizes) { + private CameraCharacteristics mockCharacteristics(int facing, Size[] outputSizes, + int sensorOrientation) { CameraCharacteristics chars = mock(CameraCharacteristics.class); StreamConfigurationMap map = mock(StreamConfigurationMap.class); @@ -49,32 +62,62 @@ private CameraCharacteristics mockCharacteristics(int facing, Size[] outputSizes // and SuppressWarnings handles the generic return stubbing. when(chars.get(any())) .thenReturn(facing) - .thenReturn(map); + .thenReturn(map) + .thenReturn(sensorOrientation); when(map.getOutputSizes(ImageFormat.YUV_420_888)).thenReturn(outputSizes); return chars; } + /** + * Variant with no {@link StreamConfigurationMap} (second {@code get()} returns null). + */ @SuppressWarnings("unchecked") - private CameraCharacteristics mockCharacteristicsNoMap(int facing) { + private CameraCharacteristics mockCharacteristicsNoMap(int facing, int sensorOrientation) { CameraCharacteristics chars = mock(CameraCharacteristics.class); when(chars.get(any())) .thenReturn(facing) - .thenReturn(null); // no map + .thenReturn(null) // no map + .thenReturn(sensorOrientation); + + return chars; + } + + /** + * Variant with a null lens-facing value (first {@code get()} returns null). + */ + @SuppressWarnings("unchecked") + private CameraCharacteristics mockCharacteristicsNullFacing(Size[] outputSizes, + int sensorOrientation) { + CameraCharacteristics chars = mock(CameraCharacteristics.class); + StreamConfigurationMap map = mock(StreamConfigurationMap.class); + + when(chars.get(any())) + .thenReturn(null) // null facing + .thenReturn(map) + .thenReturn(sensorOrientation); + + when(map.getOutputSizes(ImageFormat.YUV_420_888)).thenReturn(outputSizes); return chars; } + /** + * Variant where {@link CameraCharacteristics#SENSOR_ORIENTATION} is null (third + * {@code get()} returns null), exercising the null-safety default of 0. + */ @SuppressWarnings("unchecked") - private CameraCharacteristics mockCharacteristicsNullFacing(Size[] outputSizes) { + private CameraCharacteristics mockCharacteristicsNullSensorOrientation(int facing, + Size[] outputSizes) { CameraCharacteristics chars = mock(CameraCharacteristics.class); StreamConfigurationMap map = mock(StreamConfigurationMap.class); when(chars.get(any())) - .thenReturn(null) // null facing - .thenReturn(map); + .thenReturn(facing) + .thenReturn(map) + .thenReturn(null); // null sensor orientation when(map.getOutputSizes(ImageFormat.YUV_420_888)).thenReturn(outputSizes); @@ -86,14 +129,14 @@ private CameraCharacteristics mockCharacteristicsNullFacing(Size[] outputSizes) @Test public void buildRawData_cameraIdBackCamera() { CameraInfo info = new CameraInfo("0", - mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, new Size[0])); + mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, new Size[0], 90)); assertEquals("0", info.buildRawData().get("camera_id")); } @Test public void buildRawData_cameraIdFrontCamera() { CameraInfo info = new CameraInfo("1", - mockCharacteristics(CameraCharacteristics.LENS_FACING_FRONT, new Size[0])); + mockCharacteristics(CameraCharacteristics.LENS_FACING_FRONT, new Size[0], 270)); assertEquals("1", info.buildRawData().get("camera_id")); } @@ -102,21 +145,21 @@ public void buildRawData_cameraIdFrontCamera() { @Test public void buildRawData_backCamera_isFrontFacingFalse() { CameraInfo info = new CameraInfo("0", - mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, new Size[0])); + mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, new Size[0], 90)); assertFalse((Boolean) info.buildRawData().get("is_front_facing")); } @Test public void buildRawData_frontCamera_isFrontFacingTrue() { CameraInfo info = new CameraInfo("1", - mockCharacteristics(CameraCharacteristics.LENS_FACING_FRONT, new Size[0])); + mockCharacteristics(CameraCharacteristics.LENS_FACING_FRONT, new Size[0], 270)); assertTrue((Boolean) info.buildRawData().get("is_front_facing")); } @Test public void buildRawData_nullFacing_isFrontFacingFalse() { CameraInfo info = new CameraInfo("2", - mockCharacteristicsNullFacing(new Size[0])); + mockCharacteristicsNullFacing(new Size[0], 90)); assertFalse((Boolean) info.buildRawData().get("is_front_facing")); } @@ -125,7 +168,7 @@ public void buildRawData_nullFacing_isFrontFacingFalse() { @Test public void buildRawData_noOutputSizes_returnsEmptyArray() { CameraInfo info = new CameraInfo("0", - mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, new Size[0])); + mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, new Size[0], 90)); Object[] sizes = (Object[]) info.buildRawData().get("output_sizes"); assertNotNull(sizes); assertEquals(0, sizes.length); @@ -135,7 +178,7 @@ public void buildRawData_noOutputSizes_returnsEmptyArray() { public void buildRawData_twoOutputSizes_returnsTwoEntries() { Size[] sizes = {size(1920, 1080), size(1280, 720)}; CameraInfo info = new CameraInfo("0", - mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, sizes)); + mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, sizes, 90)); Object[] result = (Object[]) info.buildRawData().get("output_sizes"); assertEquals(2, result.length); } @@ -144,7 +187,7 @@ public void buildRawData_twoOutputSizes_returnsTwoEntries() { public void buildRawData_outputSizesContainWidthAndHeight() { Size[] sizes = {size(640, 480)}; CameraInfo info = new CameraInfo("0", - mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, sizes)); + mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, sizes, 90)); Object[] result = (Object[]) info.buildRawData().get("output_sizes"); Dictionary sizeDict = (Dictionary) result[0]; assertEquals(640, sizeDict.get("width")); @@ -154,7 +197,7 @@ public void buildRawData_outputSizesContainWidthAndHeight() { @Test public void buildRawData_nullStreamConfigMap_returnsEmptyOutputSizes() { CameraInfo info = new CameraInfo("0", - mockCharacteristicsNoMap(CameraCharacteristics.LENS_FACING_BACK)); + mockCharacteristicsNoMap(CameraCharacteristics.LENS_FACING_BACK, 90)); Object[] result = (Object[]) info.buildRawData().get("output_sizes"); assertNotNull(result); assertEquals(0, result.length); @@ -163,21 +206,71 @@ public void buildRawData_nullStreamConfigMap_returnsEmptyOutputSizes() { @Test public void buildRawData_nullOutputSizesArray_returnsEmptyArray() { CameraInfo info = new CameraInfo("0", - mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, null)); + mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, null, 90)); Object[] result = (Object[]) info.buildRawData().get("output_sizes"); assertNotNull(result); assertEquals(0, result.length); } + // -- sensor_orientation ------------------------------------------------ + + @Test + public void buildRawData_backCamera_sensorOrientation90() { + CameraInfo info = new CameraInfo("0", + mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, new Size[0], 90)); + assertEquals(90, info.buildRawData().get("sensor_orientation")); + } + + @Test + public void buildRawData_frontCamera_sensorOrientation270() { + CameraInfo info = new CameraInfo("1", + mockCharacteristics(CameraCharacteristics.LENS_FACING_FRONT, new Size[0], 270)); + assertEquals(270, info.buildRawData().get("sensor_orientation")); + } + + @Test + public void buildRawData_sensorOrientation0_returnsZero() { + CameraInfo info = new CameraInfo("0", + mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, new Size[0], 0)); + assertEquals(0, info.buildRawData().get("sensor_orientation")); + } + + @Test + public void buildRawData_sensorOrientation180_returns180() { + CameraInfo info = new CameraInfo("0", + mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, new Size[0], 180)); + assertEquals(180, info.buildRawData().get("sensor_orientation")); + } + + @Test + public void buildRawData_nullSensorOrientation_defaultsToZero() { + CameraInfo info = new CameraInfo("0", + mockCharacteristicsNullSensorOrientation( + CameraCharacteristics.LENS_FACING_BACK, new Size[0])); + assertEquals(0, info.buildRawData().get("sensor_orientation")); + } + + @Test + public void buildRawData_nullSensorOrientation_doesNotThrow() { + CameraInfo info = new CameraInfo("0", + mockCharacteristicsNullSensorOrientation( + CameraCharacteristics.LENS_FACING_BACK, new Size[0])); + // Must not throw; must contain the key with the default value + Dictionary d = info.buildRawData(); + assertTrue(d.containsKey("sensor_orientation")); + assertEquals(0, d.get("sensor_orientation")); + } + // -- key completeness ------------------------------------------------- @Test public void buildRawData_containsAllExpectedKeys() { CameraInfo info = new CameraInfo("0", - mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, new Size[0])); + mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, new Size[0], 90)); Dictionary d = info.buildRawData(); assertTrue(d.containsKey("camera_id")); assertTrue(d.containsKey("is_front_facing")); assertTrue(d.containsKey("output_sizes")); + assertTrue(d.containsKey("sensor_orientation")); } } diff --git a/common/config/plugin.properties b/common/config/plugin.properties index f9d694a..8c1d01b 100644 --- a/common/config/plugin.properties +++ b/common/config/plugin.properties @@ -5,4 +5,4 @@ pluginNodeName=NativeCamera pluginModuleName=native_camera pluginPackage=org.godotengine.plugin.nativecamera -pluginVersion=2.0 +pluginVersion=3.0 diff --git a/demo/Main.gd b/demo/Main.gd index 440bb77..cef1cb0 100644 --- a/demo/Main.gd +++ b/demo/Main.gd @@ -53,9 +53,14 @@ func _on_get_button_pressed() -> void: var __cameras_array = camera_node.get_all_cameras() for __camera_info in __cameras_array: _cameras[__camera_info.get_camera_id()] = __camera_info - print("Available size:") + _print_to_screen("Camera %s: front-facing?=%s, sensorOrientation=%d" % [ + __camera_info.get_camera_id(), + str(__camera_info.is_front_facing()), + __camera_info.get_sensor_orientation() + ]) + _print_to_screen("Available output sizes:") for __size: FrameSize in __camera_info.get_output_sizes(): - print("[%d,%d]" % [__size.get_width(), __size.get_height()]) + _print_to_screen("[%d,%d]" % [__size.get_width(), __size.get_height()]) cameras_option_button.add_item(__camera_info.get_camera_id()) if not __cameras_array.is_empty(): diff --git a/demo/addons/GMPShared/FrameInfo.gd.uid b/demo/addons/GMPShared/FrameInfo.gd.uid new file mode 100644 index 0000000..53c7e62 --- /dev/null +++ b/demo/addons/GMPShared/FrameInfo.gd.uid @@ -0,0 +1 @@ +uid://3nivbhilp5kc diff --git a/demo/addons/GMPShared/FrameSize.gd.uid b/demo/addons/GMPShared/FrameSize.gd.uid new file mode 100644 index 0000000..92fd88c --- /dev/null +++ b/demo/addons/GMPShared/FrameSize.gd.uid @@ -0,0 +1 @@ +uid://0nvtl37flq1c diff --git a/demo/addons/NativeCameraPlugin/model/CameraInfo.gd.uid b/demo/addons/NativeCameraPlugin/model/CameraInfo.gd.uid index 44eb4ac..d1bca15 100644 --- a/demo/addons/NativeCameraPlugin/model/CameraInfo.gd.uid +++ b/demo/addons/NativeCameraPlugin/model/CameraInfo.gd.uid @@ -1 +1 @@ -uid://cpkga3i4ky2d0 +uid://cltabpju1wo4a diff --git a/demo/addons/NativeCameraPlugin/model/FeedRequest.gd.uid b/demo/addons/NativeCameraPlugin/model/FeedRequest.gd.uid index 4ecbcf2..0907294 100644 --- a/demo/addons/NativeCameraPlugin/model/FeedRequest.gd.uid +++ b/demo/addons/NativeCameraPlugin/model/FeedRequest.gd.uid @@ -1 +1 @@ -uid://bl58f6bhh4jp5 +uid://dy36kbvkhhp7q diff --git a/demo/addons/NativeCameraPlugin/model/FrameInfo.gd.uid b/demo/addons/NativeCameraPlugin/model/FrameInfo.gd.uid deleted file mode 100644 index b2cd189..0000000 --- a/demo/addons/NativeCameraPlugin/model/FrameInfo.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cmfayawhvpwtj diff --git a/demo/addons/NativeCameraPlugin/model/FrameSize.gd.uid b/demo/addons/NativeCameraPlugin/model/FrameSize.gd.uid deleted file mode 100644 index 0610fb1..0000000 --- a/demo/addons/NativeCameraPlugin/model/FrameSize.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d34q1vxftnsyw diff --git a/demo/project.godot b/demo/project.godot index 29871d2..4351879 100644 --- a/demo/project.godot +++ b/demo/project.godot @@ -15,6 +15,7 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true [application] config/name="Native Camera Demo" +config/tags=PackedStringArray("android", "camera", "demo", "ios", "plugin") run/main_scene="uid://daro5x6n3pd26" config/features=PackedStringArray("4.7", "GL Compatibility") config/icon="res://assets/native-camera-android.png" diff --git a/docs/README.md b/docs/README.md index a437a5d..79d5db6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -232,6 +232,8 @@ Encapsulates camera metadata provided by the mobile OS. * `get_camera_id() -> String` * `is_front_facing() -> bool` * `get_output_sizes() -> Array[FrameSize]` +* `get_sensor_orientation() -> int` + * Returns the clockwise angle in degrees (`0`, `90`, `180`, or `270`) that the camera sensor image must be rotated to be upright when the device is held in its natural (portrait) orientation. This value is a fixed hardware property of the camera and does not change with device rotation. Defaults to `0` when the value is unavailable (e.g. on emulators). Useful when implementing custom frame processing that needs to account for sensor mounting angle independently of device orientation. ### FeedRequest diff --git a/ios/src/NativeCamera.swift b/ios/src/NativeCamera.swift index 2f9d4ae..791590f 100644 --- a/ios/src/NativeCamera.swift +++ b/ios/src/NativeCamera.swift @@ -73,10 +73,34 @@ import UIKit let dims = CMVideoFormatDescriptionGetDimensions(format.formatDescription) return FrameSize(width: Int(dims.width), height: Int(dims.height)) } - return CameraInfo(id: device.uniqueID, device: device, outputSizes: sizes) + let so = NativeCamera.deriveSensorOrientation(from: device) + return CameraInfo(id: device.uniqueID, device: device, outputSizes: sizes, + sensorOrientation: so) } } + /// Derives the sensor orientation in degrees (0, 90, 180, or 270) from the + /// first available capture format on the given device. + /// + /// On iPhone, the built-in camera sensor is mounted in landscape orientation, so + /// the raw frame buffer always needs 90° of clockwise rotation to appear upright + /// in the device's natural (portrait) orientation. On some iPad models the camera + /// may be mounted differently, resulting in 0°. The value is a fixed hardware + /// property and does not change with device orientation. + /// + /// - Parameter device: The `AVCaptureDevice` to inspect. + /// - Returns: `90` if the first format's native width is greater than or equal to + /// its height (landscape-mounted sensor — the common case for all iPhones), or + /// `0` for a portrait-mounted sensor. Defaults to `90` when no formats are + /// available, matching the expected behaviour on all current iPhone hardware. + internal static func deriveSensorOrientation(from device: AVCaptureDevice) -> Int { + guard let format = device.formats.first else { return 90 } + let dims = CMVideoFormatDescriptionGetDimensions(format.formatDescription) + // A landscape-native sensor (width >= height) in a portrait-natural device + // needs 90° CW rotation to produce an upright image — the standard for all iPhones. + return dims.width >= dims.height ? 90 : 0 + } + @objc public func start(request: FrameRequest) { // UIDevice orientation tracking must be started on the main thread. // The notification fires on the main thread too, keeping deviceOrientation diff --git a/ios/src/model/CameraInfo.swift b/ios/src/model/CameraInfo.swift index 2faec80..29fee4b 100644 --- a/ios/src/model/CameraInfo.swift +++ b/ios/src/model/CameraInfo.swift @@ -10,11 +10,24 @@ import Foundation @objc let device: AVCaptureDevice @objc let outputSizes: [FrameSize] - @objc(initWithId:device:outputSizes:) - init(id cameraId: String, device: AVCaptureDevice, outputSizes: [FrameSize]) { + /// The clockwise angle in degrees (0, 90, 180, or 270) that the camera + /// sensor image must be rotated to be upright when the device is held in + /// its natural (portrait) orientation. + /// + /// On iPhone, all built-in cameras capture natively in landscape, so this + /// value is 90°. On some iPad models the camera may be mounted differently + /// and return 0°. The value is a fixed hardware property and does not + /// change with device rotation. It is used alongside `isFrontFacing` to + /// implement custom frame-processing pipelines that must account for sensor + /// mounting angle independently of live device orientation. + @objc let sensorOrientation: Int + + @objc(initWithId:device:outputSizes:sensorOrientation:) + init(id cameraId: String, device: AVCaptureDevice, outputSizes: [FrameSize], sensorOrientation: Int) { self.id = cameraId self.device = device self.outputSizes = outputSizes + self.sensorOrientation = sensorOrientation super.init() } } diff --git a/ios/src/model/camera_info_wrapper.mm b/ios/src/model/camera_info_wrapper.mm index f7d54fc..bd99296 100644 --- a/ios/src/model/camera_info_wrapper.mm +++ b/ios/src/model/camera_info_wrapper.mm @@ -11,6 +11,7 @@ @implementation CameraInfoWrapper static String const kCameraIdProperty = "camera_id"; static String const kIsFrontFacingProperty = "is_front_facing"; static String const kOutputSizesProperty = "output_sizes"; +static String const kSensorOrientationProperty = "sensor_orientation"; - (instancetype)initWithCameraInfo:(CameraInfo *)cameraInfo { self = [super init]; @@ -37,6 +38,12 @@ - (Dictionary)buildRawData { dict[kOutputSizesProperty] = dictArray; + // SENSOR_ORIENTATION is the clockwise angle (0, 90, 180, or 270 degrees) that + // the camera image must be rotated to be upright when the device is in its + // natural (portrait) orientation. On iPhone this is always 90°; on some iPad + // models it may be 0°. Mirrors the Android Camera2 SENSOR_ORIENTATION field. + dict[kSensorOrientationProperty] = (int)self.cameraInfo.sensorOrientation; + return dict; } diff --git a/ios/test/unit/NativeCameraTests.swift b/ios/test/unit/NativeCameraTests.swift index e88add6..fd68072 100644 --- a/ios/test/unit/NativeCameraTests.swift +++ b/ios/test/unit/NativeCameraTests.swift @@ -523,6 +523,15 @@ final class NativeCameraLifecycleTests: XCTestCase { } } + func test_getCameras_eachEntry_hasSensorOrientationInValidRange() { + let cameras = NativeCamera().getCameras() + let valid = Set([0, 90, 180, 270]) + for cam in cameras { + XCTAssertTrue(valid.contains(cam.sensorOrientation), + "sensorOrientation \(cam.sensorOrientation) is not a canonical angle (0/90/180/270)") + } + } + func test_onFrameAvailable_callbackIsOptional_atInit() { let camera = NativeCamera() XCTAssertNil(camera.onFrameAvailable) @@ -828,3 +837,139 @@ final class NativeCameraAutoUprightTests: XCTestCase { "Result must update immediately when isFrontFacingCamera changes") } } + +// MARK: - CameraInfo Tests + +/// Tests for the `CameraInfo` model, focusing on the `sensorOrientation` property. +/// +/// `CameraInfo.init` requires a real `AVCaptureDevice`; tests that need one are +/// guarded by `XCTSkip` so they are silently skipped on a Simulator host with +/// no physical cameras attached. +final class CameraInfoTests: XCTestCase { + + // MARK: - sensorOrientation stored at init + + func test_sensorOrientation_90_isStoredAtInit() throws { + let dev = AVCaptureDevice.default(for: .video) + try XCTSkipIf(dev == nil, "No camera available on this host") + let info = CameraInfo(id: "cam0", device: dev!, outputSizes: [], sensorOrientation: 90) + XCTAssertEqual(info.sensorOrientation, 90) + } + + func test_sensorOrientation_0_isStoredAtInit() throws { + let dev = AVCaptureDevice.default(for: .video) + try XCTSkipIf(dev == nil, "No camera available on this host") + let info = CameraInfo(id: "cam0", device: dev!, outputSizes: [], sensorOrientation: 0) + XCTAssertEqual(info.sensorOrientation, 0) + } + + func test_sensorOrientation_180_isStoredAtInit() throws { + let dev = AVCaptureDevice.default(for: .video) + try XCTSkipIf(dev == nil, "No camera available on this host") + let info = CameraInfo(id: "cam0", device: dev!, outputSizes: [], sensorOrientation: 180) + XCTAssertEqual(info.sensorOrientation, 180) + } + + func test_sensorOrientation_270_isStoredAtInit() throws { + let dev = AVCaptureDevice.default(for: .video) + try XCTSkipIf(dev == nil, "No camera available on this host") + let info = CameraInfo(id: "cam0", device: dev!, outputSizes: [], sensorOrientation: 270) + XCTAssertEqual(info.sensorOrientation, 270) + } + + // MARK: - sensorOrientation does not interfere with other stored properties + + func test_sensorOrientation_doesNotAffectId() throws { + let dev = AVCaptureDevice.default(for: .video) + try XCTSkipIf(dev == nil, "No camera available on this host") + let info = CameraInfo(id: "unique-id", device: dev!, outputSizes: [], sensorOrientation: 90) + XCTAssertEqual(info.id, "unique-id") + } + + func test_sensorOrientation_doesNotAffectOutputSizesCount() throws { + let dev = AVCaptureDevice.default(for: .video) + try XCTSkipIf(dev == nil, "No camera available on this host") + let sizes = [FrameSize(width: 1280, height: 720), FrameSize(width: 640, height: 480)] + let info = CameraInfo(id: "cam0", device: dev!, outputSizes: sizes, sensorOrientation: 90) + XCTAssertEqual(info.outputSizes.count, 2) + } +} + +// MARK: - NativeCamera.deriveSensorOrientation Tests + +/// Tests for `NativeCamera.deriveSensorOrientation(from:)`. +/// +/// The helper is `internal static`, making it accessible via `@testable import`. +/// Because it requires a real `AVCaptureDevice`, every test that constructs one +/// is guarded by `XCTSkip` for Simulator hosts with no cameras. +/// +/// ## What is tested here +/// +/// | Scenario | Expected result | +/// |---|---| +/// | Device has landscape-native formats (width ≥ height) | 90° | +/// | Device has no formats | 90° (safe default) | +/// | All cameras returned by getCameras() | value in {0, 90, 180, 270} | +final class NativeCameraDeriveSensorOrientationTests: XCTestCase { + + // MARK: - Default when no formats are available + + /// The helper must never throw and must return a sensible default when the + /// device exposes no formats. We cannot construct a formatless device in + /// a unit test without private API, so this scenario is verified by checking + /// that `deriveSensorOrientation` on any real device returns a canonical value. + func test_deriveSensorOrientation_realDevice_returnsCanonicalValue() throws { + let dev = AVCaptureDevice.default(for: .video) + try XCTSkipIf(dev == nil, "No camera available on this host") + let result = NativeCamera.deriveSensorOrientation(from: dev!) + XCTAssertTrue([0, 90, 180, 270].contains(result), + "deriveSensorOrientation returned non-canonical value \(result)") + } + + // MARK: - iPhone built-in back camera is landscape-native → 90° + + func test_deriveSensorOrientation_backCamera_returns90() throws { + guard let dev = AVCaptureDevice.default(.builtInWideAngleCamera, + for: .video, position: .back) else { + throw XCTSkip("No back camera available on this host") + } + // All iPhone back cameras capture in landscape natively. + XCTAssertEqual(NativeCamera.deriveSensorOrientation(from: dev), 90, + "iPhone back camera should report sensorOrientation 90") + } + + func test_deriveSensorOrientation_frontCamera_returnsCanonicalValue() throws { + guard let dev = AVCaptureDevice.default(.builtInWideAngleCamera, + for: .video, position: .front) else { + throw XCTSkip("No front camera available on this host") + } + let result = NativeCamera.deriveSensorOrientation(from: dev) + XCTAssertTrue([0, 90, 180, 270].contains(result), + "Front camera sensorOrientation \(result) is not canonical") + } + + // MARK: - getCameras() integration: sensorOrientation propagates end-to-end + + /// When `getCameras()` is called on a host that has at least one camera, + /// every returned `CameraInfo` must carry a `sensorOrientation` that is a + /// member of {0, 90, 180, 270}. + func test_getCameras_sensorOrientation_isCanonicalForAllCameras() { + let cameras = NativeCamera().getCameras() + let valid = Set([0, 90, 180, 270]) + for cam in cameras { + XCTAssertTrue(valid.contains(cam.sensorOrientation), + "Camera '\(cam.id)' sensorOrientation \(cam.sensorOrientation) is not canonical") + } + } + + /// Confirms that `getCameras()` propagates `sensorOrientation` into `CameraInfo` + /// by checking that the property is non-negative for every camera. + /// (A value of 0 is valid for landscape-native iPads; negative would be a bug.) + func test_getCameras_sensorOrientation_isNonNegative() { + let cameras = NativeCamera().getCameras() + for cam in cameras { + XCTAssertGreaterThanOrEqual(cam.sensorOrientation, 0, + "Camera '\(cam.id)' has negative sensorOrientation") + } + } +} diff --git a/ios/test/unit/WrapperTests.mm b/ios/test/unit/WrapperTests.mm index c85a20a..f15cf57 100644 --- a/ios/test/unit/WrapperTests.mm +++ b/ios/test/unit/WrapperTests.mm @@ -39,13 +39,17 @@ } /// Build a minimal CameraInfo with one output size – does NOT need a real AVCaptureDevice -/// because CameraInfo stores the device reference but the wrapper only reads `id` and `outputSizes`. +/// because CameraInfo stores the device reference but the wrapper only reads `id`, +/// `outputSizes`, and `sensorOrientation` for non-position fields. /// We pass nil for device; accessing device.position will crash, so CameraInfoWrapper tests /// using isFrontFacing must provide a real device or be skipped on Simulator. -static CameraInfo *make_camera_info_without_device(NSString *cameraId, NSArray *sizes) { +static CameraInfo *make_camera_info_without_device(NSString *cameraId, + NSArray *sizes, + int sensorOrientation) { // Construct with nil device – only safe when the test doesn't call buildRawData - // (which reads device.position). Use make_camera_info_with_back_position for full tests. - return [[CameraInfo alloc] initWithId:cameraId device:nil outputSizes:sizes]; + // (which reads device.position). Use a real device for full integration tests. + return [[CameraInfo alloc] initWithId:cameraId device:nil outputSizes:sizes + sensorOrientation:sensorOrientation]; } // --------------------------------------------------------------------------- @@ -224,15 +228,15 @@ @interface CameraInfoWrapperTests : XCTestCase @implementation CameraInfoWrapperTests -// MARK: Key presence (device = nil, is_front_facing derivation is skipped) +// MARK: Key presence (requires a real AVCaptureDevice for device.position access) - (void)test_buildRawData_containsCameraIdKey { NSArray *sizes = @[make_frame_size(1280, 720)]; - // Use a real device from AVCaptureDevice when available; otherwise skip isFrontFacing sub-test. AVCaptureDevice *dev = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo]; if (!dev) { XCTSkip(@"No camera available on this host"); } - CameraInfo *info = [[CameraInfo alloc] initWithId:@"test-id" device:dev outputSizes:sizes]; + CameraInfo *info = [[CameraInfo alloc] initWithId:@"test-id" device:dev + outputSizes:sizes sensorOrientation:90]; CameraInfoWrapper *w = [[CameraInfoWrapper alloc] initWithCameraInfo:info]; XCTAssertDictHasKey([w buildRawData], String("camera_id")); } @@ -241,7 +245,8 @@ - (void)test_buildRawData_containsIsFrontFacingKey { AVCaptureDevice *dev = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo]; if (!dev) { XCTSkip(@"No camera available on this host"); } NSArray *sizes = @[make_frame_size(640, 480)]; - CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev outputSizes:sizes]; + CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev + outputSizes:sizes sensorOrientation:90]; XCTAssertDictHasKey([[CameraInfoWrapper alloc] initWithCameraInfo:info].buildRawData, String("is_front_facing")); } @@ -250,29 +255,75 @@ - (void)test_buildRawData_containsOutputSizesKey { AVCaptureDevice *dev = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo]; if (!dev) { XCTSkip(@"No camera available on this host"); } NSArray *sizes = @[make_frame_size(640, 480)]; - CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev outputSizes:sizes]; + CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev + outputSizes:sizes sensorOrientation:90]; XCTAssertDictHasKey([[CameraInfoWrapper alloc] initWithCameraInfo:info].buildRawData, String("output_sizes")); } +- (void)test_buildRawData_containsSensorOrientationKey { + AVCaptureDevice *dev = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo]; + if (!dev) { XCTSkip(@"No camera available on this host"); } + NSArray *sizes = @[make_frame_size(1280, 720)]; + CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev + outputSizes:sizes sensorOrientation:90]; + XCTAssertDictHasKey([[CameraInfoWrapper alloc] initWithCameraInfo:info].buildRawData, + String("sensor_orientation")); +} + // MARK: camera_id value - (void)test_buildRawData_cameraId_matchesInput { AVCaptureDevice *dev = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo]; if (!dev) { XCTSkip(@"No camera available on this host"); } NSArray *sizes = @[make_frame_size(1280, 720)]; - CameraInfo *info = [[CameraInfo alloc] initWithId:@"my-unique-camera-id" device:dev outputSizes:sizes]; + CameraInfo *info = [[CameraInfo alloc] initWithId:@"my-unique-camera-id" device:dev + outputSizes:sizes sensorOrientation:90]; Dictionary d = [[CameraInfoWrapper alloc] initWithCameraInfo:info].buildRawData; XCTAssertGodotStringEqual(dict_get_string(d, String("camera_id")), String("my-unique-camera-id")); } +// MARK: sensor_orientation value + +- (void)test_buildRawData_sensorOrientation_90_isPreserved { + AVCaptureDevice *dev = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo]; + if (!dev) { XCTSkip(@"No camera available on this host"); } + NSArray *sizes = @[make_frame_size(1280, 720)]; + CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev + outputSizes:sizes sensorOrientation:90]; + Dictionary d = [[CameraInfoWrapper alloc] initWithCameraInfo:info].buildRawData; + XCTAssertEqual(dict_get_int(d, String("sensor_orientation")), 90); +} + +- (void)test_buildRawData_sensorOrientation_0_isPreserved { + AVCaptureDevice *dev = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo]; + if (!dev) { XCTSkip(@"No camera available on this host"); } + NSArray *sizes = @[make_frame_size(1280, 720)]; + CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev + outputSizes:sizes sensorOrientation:0]; + Dictionary d = [[CameraInfoWrapper alloc] initWithCameraInfo:info].buildRawData; + XCTAssertEqual(dict_get_int(d, String("sensor_orientation")), 0); +} + +- (void)test_buildRawData_sensorOrientation_isIntType { + AVCaptureDevice *dev = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo]; + if (!dev) { XCTSkip(@"No camera available on this host"); } + NSArray *sizes = @[make_frame_size(1280, 720)]; + CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev + outputSizes:sizes sensorOrientation:90]; + Dictionary d = [[CameraInfoWrapper alloc] initWithCameraInfo:info].buildRawData; + Variant v = d[String("sensor_orientation")]; + XCTAssertEqual(v.get_type(), Variant::INT); +} + // MARK: output_sizes array - (void)test_buildRawData_outputSizes_isArray { AVCaptureDevice *dev = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo]; if (!dev) { XCTSkip(@"No camera available on this host"); } NSArray *sizes = @[make_frame_size(640, 480), make_frame_size(1280, 720)]; - CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev outputSizes:sizes]; + CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev + outputSizes:sizes sensorOrientation:90]; Dictionary d = [[CameraInfoWrapper alloc] initWithCameraInfo:info].buildRawData; Variant sizesVar = d[String("output_sizes")]; XCTAssertEqual(sizesVar.get_type(), Variant::ARRAY); @@ -282,7 +333,8 @@ - (void)test_buildRawData_outputSizes_countMatchesInput { AVCaptureDevice *dev = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo]; if (!dev) { XCTSkip(@"No camera available on this host"); } NSArray *sizes = @[make_frame_size(640, 480), make_frame_size(1280, 720), make_frame_size(1920, 1080)]; - CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev outputSizes:sizes]; + CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev + outputSizes:sizes sensorOrientation:90]; Dictionary d = [[CameraInfoWrapper alloc] initWithCameraInfo:info].buildRawData; Array godotSizes = (Array)d[String("output_sizes")]; XCTAssertEqual((int)godotSizes.size(), 3); @@ -291,7 +343,8 @@ - (void)test_buildRawData_outputSizes_countMatchesInput { - (void)test_buildRawData_outputSizes_emptyArray_yieldsEmptyGodotArray { AVCaptureDevice *dev = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo]; if (!dev) { XCTSkip(@"No camera available on this host"); } - CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev outputSizes:@[]]; + CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev + outputSizes:@[] sensorOrientation:90]; Dictionary d = [[CameraInfoWrapper alloc] initWithCameraInfo:info].buildRawData; Array godotSizes = (Array)d[String("output_sizes")]; XCTAssertEqual((int)godotSizes.size(), 0); @@ -301,7 +354,8 @@ - (void)test_buildRawData_outputSizes_firstEntry_hasWidthAndHeight { AVCaptureDevice *dev = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo]; if (!dev) { XCTSkip(@"No camera available on this host"); } NSArray *sizes = @[make_frame_size(320, 240)]; - CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev outputSizes:sizes]; + CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev + outputSizes:sizes sensorOrientation:90]; Dictionary d = [[CameraInfoWrapper alloc] initWithCameraInfo:info].buildRawData; Array godotSizes = (Array)d[String("output_sizes")]; Dictionary first = (Dictionary)godotSizes[0]; @@ -311,11 +365,12 @@ - (void)test_buildRawData_outputSizes_firstEntry_hasWidthAndHeight { XCTAssertEqual(dict_get_int(first, String("height")), 240); } -- (void)test_buildRawData_exactlyThreeKeys { +- (void)test_buildRawData_exactlyFourKeys { AVCaptureDevice *dev = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo]; if (!dev) { XCTSkip(@"No camera available on this host"); } - CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev outputSizes:@[]]; - XCTAssertEqual([[CameraInfoWrapper alloc] initWithCameraInfo:info].buildRawData.size(), 3); + CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev + outputSizes:@[] sensorOrientation:90]; + XCTAssertEqual([[CameraInfoWrapper alloc] initWithCameraInfo:info].buildRawData.size(), 4); } @end From 5716a831e1ec5f6cfec6a49c3f4700178cfca3c7 Mon Sep 17 00:00:00 2001 From: Cengiz Date: Sat, 9 May 2026 12:38:03 +0600 Subject: [PATCH 2/2] Fixed style errors --- demo/Main.gd | 15 +- ios/plugin.xcodeproj/project.pbxproj | 24 +- ios/test/unit/CameraInfoTests.swift | 143 ++++++ .../unit/NativeCameraAutoUprightTests.swift | 302 ++++++++++++ ios/test/unit/NativeCameraTests.swift | 455 +----------------- 5 files changed, 483 insertions(+), 456 deletions(-) create mode 100644 ios/test/unit/CameraInfoTests.swift create mode 100644 ios/test/unit/NativeCameraAutoUprightTests.swift diff --git a/demo/Main.gd b/demo/Main.gd index cef1cb0..7dac209 100644 --- a/demo/Main.gd +++ b/demo/Main.gd @@ -53,11 +53,16 @@ func _on_get_button_pressed() -> void: var __cameras_array = camera_node.get_all_cameras() for __camera_info in __cameras_array: _cameras[__camera_info.get_camera_id()] = __camera_info - _print_to_screen("Camera %s: front-facing?=%s, sensorOrientation=%d" % [ - __camera_info.get_camera_id(), - str(__camera_info.is_front_facing()), - __camera_info.get_sensor_orientation() - ]) + _print_to_screen( + ( + "Camera %s: front-facing?=%s, sensorOrientation=%d" + % [ + __camera_info.get_camera_id(), + str(__camera_info.is_front_facing()), + __camera_info.get_sensor_orientation() + ] + ) + ) _print_to_screen("Available output sizes:") for __size: FrameSize in __camera_info.get_output_sizes(): _print_to_screen("[%d,%d]" % [__size.get_width(), __size.get_height()]) diff --git a/ios/plugin.xcodeproj/project.pbxproj b/ios/plugin.xcodeproj/project.pbxproj index f28cd41..f32c1dd 100644 --- a/ios/plugin.xcodeproj/project.pbxproj +++ b/ios/plugin.xcodeproj/project.pbxproj @@ -10,6 +10,8 @@ 0721C6A22F82629000A9BA75 /* libnative_camera_plugin.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 90CAAA9424E71FF10013969F /* libnative_camera_plugin.a */; }; 0721C6C12F82639B00A9BA75 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0721C6C02F82639B00A9BA75 /* XCTest.framework */; platformFilter = ios; }; 072C41E12F8F2AA300BA4867 /* AVFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 072C41E02F8F2AA200BA4867 /* AVFoundation.framework */; }; + 078F64A12FAF0C8A005B6CE8 /* CameraInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 078F649F2FAF0C8A005B6CE8 /* CameraInfoTests.swift */; }; + 078F64A22FAF0C8A005B6CE8 /* NativeCameraAutoUprightTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 078F64A02FAF0C8A005B6CE8 /* NativeCameraAutoUprightTests.swift */; }; 07A6C6802F8F73FE00F484DA /* NativeCameraTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07A6C67C2F8F73FE00F484DA /* NativeCameraTests.swift */; }; 07A6C6812F8F73FE00F484DA /* TestFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07A6C6782F8F73FE00F484DA /* TestFixtures.swift */; }; 07A6C6822F8F73FE00F484DA /* WrapperTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 07A6C67D2F8F73FE00F484DA /* WrapperTests.mm */; }; @@ -53,6 +55,8 @@ 0721C69E2F82629000A9BA75 /* native_camera_plugin_tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = native_camera_plugin_tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 0721C6C02F82639B00A9BA75 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; 072C41E02F8F2AA200BA4867 /* AVFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFoundation.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX26.0.sdk/System/Library/Frameworks/AVFoundation.framework; sourceTree = DEVELOPER_DIR; }; + 078F649F2FAF0C8A005B6CE8 /* CameraInfoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraInfoTests.swift; sourceTree = ""; }; + 078F64A02FAF0C8A005B6CE8 /* NativeCameraAutoUprightTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeCameraAutoUprightTests.swift; sourceTree = ""; }; 07A6C6782F8F73FE00F484DA /* TestFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestFixtures.swift; sourceTree = ""; }; 07A6C67A2F8F73FE00F484DA /* FrameRequestTests.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = FrameRequestTests.mm; sourceTree = ""; }; 07A6C67B2F8F73FE00F484DA /* native_camera_test_helpers.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = native_camera_test_helpers.h; sourceTree = ""; }; @@ -112,6 +116,8 @@ 07A6C67E2F8F73FE00F484DA /* unit */ = { isa = PBXGroup; children = ( + 078F649F2FAF0C8A005B6CE8 /* CameraInfoTests.swift */, + 078F64A02FAF0C8A005B6CE8 /* NativeCameraAutoUprightTests.swift */, 07A6C67A2F8F73FE00F484DA /* FrameRequestTests.mm */, 07A6C67B2F8F73FE00F484DA /* native_camera_test_helpers.h */, 07A6C67C2F8F73FE00F484DA /* NativeCameraTests.swift */, @@ -286,6 +292,8 @@ buildActionMask = 2147483647; files = ( 07A6C6802F8F73FE00F484DA /* NativeCameraTests.swift in Sources */, + 078F64A12FAF0C8A005B6CE8 /* CameraInfoTests.swift in Sources */, + 078F64A22FAF0C8A005B6CE8 /* NativeCameraAutoUprightTests.swift in Sources */, 07A6C6812F8F73FE00F484DA /* TestFixtures.swift in Sources */, 07A6C6822F8F73FE00F484DA /* WrapperTests.mm in Sources */, 07A6C6832F8F73FE00F484DA /* FrameRequestTests.mm in Sources */, @@ -354,10 +362,6 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; - OTHER_LDFLAGS = ( - "$(inherited)", - "$(GODOT_DIR)/bin/libgodot.ios.template_debug.$(CURRENT_ARCH).simulator.a", - ); OTHER_CFLAGS = ( "-g", "-DDEBUG", @@ -406,6 +410,10 @@ "-isystem", "$(GODOT_DIR)/platform/ios", ); + OTHER_LDFLAGS = ( + "$(inherited)", + "$(GODOT_DIR)/bin/libgodot.ios.template_debug.$(CURRENT_ARCH).simulator.a", + ); PRODUCT_BUNDLE_IDENTIFIER = "org.godotengine.plugin.native-camera-unit-tests"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -452,10 +460,6 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; - OTHER_LDFLAGS = ( - "$(inherited)", - "$(GODOT_DIR)/bin/libgodot.ios.template_debug.$(CURRENT_ARCH).simulator.a", - ); OTHER_CFLAGS = ( "-fmodules", "-fobjc-arc", @@ -495,6 +499,10 @@ "-isystem", "$(GODOT_DIR)/platform/ios", ); + OTHER_LDFLAGS = ( + "$(inherited)", + "$(GODOT_DIR)/bin/libgodot.ios.template_debug.$(CURRENT_ARCH).simulator.a", + ); PRODUCT_BUNDLE_IDENTIFIER = "org.godotengine.plugin.native-camera-unit-tests"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/ios/test/unit/CameraInfoTests.swift b/ios/test/unit/CameraInfoTests.swift new file mode 100644 index 0000000..b265579 --- /dev/null +++ b/ios/test/unit/CameraInfoTests.swift @@ -0,0 +1,143 @@ +// +// © 2026-present https://github.com/cengiz-pz +// + +import AVFoundation +@testable import native_camera_plugin +import XCTest + +// MARK: - CameraInfo Tests + +/// Tests for the `CameraInfo` model, focusing on the `sensorOrientation` property. +/// +/// `CameraInfo.init` requires a real `AVCaptureDevice`; tests that need one are +/// guarded by `XCTSkip` so they are silently skipped on a Simulator host with +/// no physical cameras attached. +final class CameraInfoTests: XCTestCase { + + // MARK: - sensorOrientation stored at init + + func test_sensorOrientation_90_isStoredAtInit() throws { + let dev = AVCaptureDevice.default(for: .video) + try XCTSkipIf(dev == nil, "No camera available on this host") + let info = CameraInfo(id: "cam0", device: dev!, outputSizes: [], sensorOrientation: 90) + XCTAssertEqual(info.sensorOrientation, 90) + } + + func test_sensorOrientation_0_isStoredAtInit() throws { + let dev = AVCaptureDevice.default(for: .video) + try XCTSkipIf(dev == nil, "No camera available on this host") + let info = CameraInfo(id: "cam0", device: dev!, outputSizes: [], sensorOrientation: 0) + XCTAssertEqual(info.sensorOrientation, 0) + } + + func test_sensorOrientation_180_isStoredAtInit() throws { + let dev = AVCaptureDevice.default(for: .video) + try XCTSkipIf(dev == nil, "No camera available on this host") + let info = CameraInfo(id: "cam0", device: dev!, outputSizes: [], sensorOrientation: 180) + XCTAssertEqual(info.sensorOrientation, 180) + } + + func test_sensorOrientation_270_isStoredAtInit() throws { + let dev = AVCaptureDevice.default(for: .video) + try XCTSkipIf(dev == nil, "No camera available on this host") + let info = CameraInfo(id: "cam0", device: dev!, outputSizes: [], sensorOrientation: 270) + XCTAssertEqual(info.sensorOrientation, 270) + } + + // MARK: - sensorOrientation does not interfere with other stored properties + + func test_sensorOrientation_doesNotAffectId() throws { + let dev = AVCaptureDevice.default(for: .video) + try XCTSkipIf(dev == nil, "No camera available on this host") + let info = CameraInfo(id: "unique-id", device: dev!, outputSizes: [], sensorOrientation: 90) + XCTAssertEqual(info.id, "unique-id") + } + + func test_sensorOrientation_doesNotAffectOutputSizesCount() throws { + let dev = AVCaptureDevice.default(for: .video) + try XCTSkipIf(dev == nil, "No camera available on this host") + let sizes = [FrameSize(width: 1280, height: 720), FrameSize(width: 640, height: 480)] + let info = CameraInfo(id: "cam0", device: dev!, outputSizes: sizes, sensorOrientation: 90) + XCTAssertEqual(info.outputSizes.count, 2) + } +} + +// MARK: - NativeCamera.deriveSensorOrientation Tests + +/// Tests for `NativeCamera.deriveSensorOrientation(from:)`. +/// +/// The helper is `internal static`, making it accessible via `@testable import`. +/// Because it requires a real `AVCaptureDevice`, every test that constructs one +/// is guarded by `XCTSkip` for Simulator hosts with no cameras. +/// +/// ## What is tested here +/// +/// | Scenario | Expected result | +/// |---|---| +/// | Device has landscape-native formats (width ≥ height) | 90° | +/// | Device has no formats | 90° (safe default) | +/// | All cameras returned by getCameras() | value in {0, 90, 180, 270} | +final class NativeCameraDeriveSensorOrientationTests: XCTestCase { + + // MARK: - Default when no formats are available + + /// The helper must never throw and must return a sensible default when the + /// device exposes no formats. We cannot construct a formatless device in + /// a unit test without private API, so this scenario is verified by checking + /// that `deriveSensorOrientation` on any real device returns a canonical value. + func test_deriveSensorOrientation_realDevice_returnsCanonicalValue() throws { + let dev = AVCaptureDevice.default(for: .video) + try XCTSkipIf(dev == nil, "No camera available on this host") + let result = NativeCamera.deriveSensorOrientation(from: dev!) + XCTAssertTrue([0, 90, 180, 270].contains(result), + "deriveSensorOrientation returned non-canonical value \(result)") + } + + // MARK: - iPhone built-in back camera is landscape-native → 90° + + func test_deriveSensorOrientation_backCamera_returns90() throws { + guard let dev = AVCaptureDevice.default(.builtInWideAngleCamera, + for: .video, position: .back) else { + throw XCTSkip("No back camera available on this host") + } + // All iPhone back cameras capture in landscape natively. + XCTAssertEqual(NativeCamera.deriveSensorOrientation(from: dev), 90, + "iPhone back camera should report sensorOrientation 90") + } + + func test_deriveSensorOrientation_frontCamera_returnsCanonicalValue() throws { + guard let dev = AVCaptureDevice.default(.builtInWideAngleCamera, + for: .video, position: .front) else { + throw XCTSkip("No front camera available on this host") + } + let result = NativeCamera.deriveSensorOrientation(from: dev) + XCTAssertTrue([0, 90, 180, 270].contains(result), + "Front camera sensorOrientation \(result) is not canonical") + } + + // MARK: - getCameras() integration: sensorOrientation propagates end-to-end + + /// When `getCameras()` is called on a host that has at least one camera, + /// every returned `CameraInfo` must carry a `sensorOrientation` that is a + /// member of {0, 90, 180, 270}. + func test_getCameras_sensorOrientation_isCanonicalForAllCameras() { + let cameras = NativeCamera().getCameras() + let valid = Set([0, 90, 180, 270]) + for cam in cameras { + XCTAssertTrue(valid.contains(cam.sensorOrientation), + "Camera '\(cam.id)' sensorOrientation \(cam.sensorOrientation) is not canonical") + } + } + + /// Confirms that `getCameras()` propagates `sensorOrientation` into `CameraInfo` + /// by checking that the property is non-negative for every camera. + /// (A value of 0 is valid for landscape-native iPads; negative would be a bug.) + func test_getCameras_sensorOrientation_isNonNegative() { + let cameras = NativeCamera().getCameras() + for cam in cameras { + XCTAssertGreaterThanOrEqual(cam.sensorOrientation, 0, + "Camera '\(cam.id)' has negative sensorOrientation") + } + } +} diff --git a/ios/test/unit/NativeCameraAutoUprightTests.swift b/ios/test/unit/NativeCameraAutoUprightTests.swift new file mode 100644 index 0000000..371e103 --- /dev/null +++ b/ios/test/unit/NativeCameraAutoUprightTests.swift @@ -0,0 +1,302 @@ +// +// © 2026-present https://github.com/cengiz-pz +// + +import AVFoundation +@testable import native_camera_plugin +import XCTest + +// MARK: - NativeCamera Auto-Upright Tests + +/// Tests for `computeUprightRotation()` and the supporting state fields. +/// +/// ## Strategy +/// +/// `computeUprightRotation()` is `internal` and therefore accessible via +/// `@testable import`. Its two inputs — `isFrontFacingCamera` and +/// `deviceOrientation` — are `internal` (promoted from `private` specifically +/// to enable this test class) so they can be injected directly. This lets +/// every orientation × camera-type combination be exercised without requiring +/// real hardware or a live capture session. +/// +/// ## Rotation table under test +/// +/// | `UIDeviceOrientation` | Back camera | Front camera | +/// |-----------------------|-------------|--------------| +/// | `.portrait` | 90° | 90° | +/// | `.portraitUpsideDown` | 270° | 270° | +/// | `.landscapeLeft` | 0° | 180° | +/// | `.landscapeRight` | 180° | 0° | +/// | `.faceUp` | 90° (fallback) | 90° (fallback) | +/// | `.faceDown` | 90° (fallback) | 90° (fallback) | +/// | `.unknown` | 90° (fallback) | 90° (fallback) | +final class NativeCameraAutoUprightTests: XCTestCase { + + private var camera: NativeCamera! + + override func setUp() { + super.setUp() + camera = NativeCamera() + } + + override func tearDown() { + camera = nil + super.tearDown() + } + + // MARK: - State defaults + + func test_isFrontFacingCamera_defaultsFalse() { + XCTAssertFalse(camera.isFrontFacingCamera, + "isFrontFacingCamera must default to false (back camera assumption)") + } + + func test_deviceOrientation_defaultsPortrait() { + XCTAssertEqual(camera.deviceOrientation, .portrait, + "deviceOrientation must default to .portrait so the first frame is always valid") + } + + // MARK: - Back camera — all four primary orientations + + /// Back, portrait: sensor is landscape; device is upright → rotate 90° CW. + func test_computeUprightRotation_backCamera_portrait_returns90() { + camera.isFrontFacingCamera = false + camera.deviceOrientation = .portrait + XCTAssertEqual(camera.computeUprightRotation(), 90) + } + + /// Back, portrait upside-down: sensor landscape, device inverted → rotate 270° CW. + func test_computeUprightRotation_backCamera_portraitUpsideDown_returns270() { + camera.isFrontFacingCamera = false + camera.deviceOrientation = .portraitUpsideDown + XCTAssertEqual(camera.computeUprightRotation(), 270) + } + + /// Back, landscapeLeft (top points left, home button right): + /// sensor already aligned with the display → 0° needed. + func test_computeUprightRotation_backCamera_landscapeLeft_returns0() { + camera.isFrontFacingCamera = false + camera.deviceOrientation = .landscapeLeft + XCTAssertEqual(camera.computeUprightRotation(), 0) + } + + /// Back, landscapeRight (top points right, home button left): + /// sensor inverted relative to the display → rotate 180°. + func test_computeUprightRotation_backCamera_landscapeRight_returns180() { + camera.isFrontFacingCamera = false + camera.deviceOrientation = .landscapeRight + XCTAssertEqual(camera.computeUprightRotation(), 180) + } + + // MARK: - Front camera — all four primary orientations + + /// Front, portrait: front sensor is horizontally mirrored but still needs + /// 90° CW to be upright — same as back camera for portrait. + func test_computeUprightRotation_frontCamera_portrait_returns90() { + camera.isFrontFacingCamera = true + camera.deviceOrientation = .portrait + XCTAssertEqual(camera.computeUprightRotation(), 90) + } + + /// Front, portrait upside-down: 270° — same as back camera for this axis. + func test_computeUprightRotation_frontCamera_portraitUpsideDown_returns270() { + camera.isFrontFacingCamera = true + camera.deviceOrientation = .portraitUpsideDown + XCTAssertEqual(camera.computeUprightRotation(), 270) + } + + /// Front, landscapeLeft: because the front sensor is horizontally mirrored, + /// the landscape cases are swapped compared to the back camera → 180°. + func test_computeUprightRotation_frontCamera_landscapeLeft_returns180() { + camera.isFrontFacingCamera = true + camera.deviceOrientation = .landscapeLeft + XCTAssertEqual(camera.computeUprightRotation(), 180) + } + + /// Front, landscapeRight: mirrored from back → 0°. + func test_computeUprightRotation_frontCamera_landscapeRight_returns0() { + camera.isFrontFacingCamera = true + camera.deviceOrientation = .landscapeRight + XCTAssertEqual(camera.computeUprightRotation(), 0) + } + + // MARK: - Fallback orientations → portrait default (90°) + + func test_computeUprightRotation_backCamera_faceUp_returns90() { + camera.isFrontFacingCamera = false + camera.deviceOrientation = .faceUp + XCTAssertEqual(camera.computeUprightRotation(), 90, + ".faceUp must fall through to the portrait default of 90°") + } + + func test_computeUprightRotation_backCamera_faceDown_returns90() { + camera.isFrontFacingCamera = false + camera.deviceOrientation = .faceDown + XCTAssertEqual(camera.computeUprightRotation(), 90, + ".faceDown must fall through to the portrait default of 90°") + } + + func test_computeUprightRotation_backCamera_unknown_returns90() { + camera.isFrontFacingCamera = false + camera.deviceOrientation = .unknown + XCTAssertEqual(camera.computeUprightRotation(), 90, + ".unknown must fall through to the portrait default of 90°") + } + + func test_computeUprightRotation_frontCamera_faceUp_returns90() { + camera.isFrontFacingCamera = true + camera.deviceOrientation = .faceUp + XCTAssertEqual(camera.computeUprightRotation(), 90) + } + + func test_computeUprightRotation_frontCamera_faceDown_returns90() { + camera.isFrontFacingCamera = true + camera.deviceOrientation = .faceDown + XCTAssertEqual(camera.computeUprightRotation(), 90) + } + + func test_computeUprightRotation_frontCamera_unknown_returns90() { + camera.isFrontFacingCamera = true + camera.deviceOrientation = .unknown + XCTAssertEqual(camera.computeUprightRotation(), 90) + } + + // MARK: - Portrait orientations are the same for both camera types + + /// Portrait cases do not involve the horizontal-mirror difference between + /// front and back sensors, so both cameras must return the same value. + func test_computeUprightRotation_portrait_sameForBothCameraTypes() { + camera.deviceOrientation = .portrait + camera.isFrontFacingCamera = false + let back = camera.computeUprightRotation() + camera.isFrontFacingCamera = true + let front = camera.computeUprightRotation() + XCTAssertEqual(back, front, "Both cameras must agree on portrait rotation") + } + + func test_computeUprightRotation_portraitUpsideDown_sameForBothCameraTypes() { + camera.deviceOrientation = .portraitUpsideDown + camera.isFrontFacingCamera = false + let back = camera.computeUprightRotation() + camera.isFrontFacingCamera = true + let front = camera.computeUprightRotation() + XCTAssertEqual(back, front, "Both cameras must agree on portrait-upside-down rotation") + } + + // MARK: - Landscape orientations differ between front and back cameras + + func test_computeUprightRotation_landscapeLeft_frontAndBackDiffer() { + camera.deviceOrientation = .landscapeLeft + camera.isFrontFacingCamera = false + let back = camera.computeUprightRotation() // 0 + camera.isFrontFacingCamera = true + let front = camera.computeUprightRotation() // 180 + XCTAssertNotEqual(back, front, + "landscapeLeft rotation must differ between front and back cameras") + } + + func test_computeUprightRotation_landscapeRight_frontAndBackDiffer() { + camera.deviceOrientation = .landscapeRight + camera.isFrontFacingCamera = false + let back = camera.computeUprightRotation() // 180 + camera.isFrontFacingCamera = true + let front = camera.computeUprightRotation() // 0 + XCTAssertNotEqual(back, front, + "landscapeRight rotation must differ between front and back cameras") + } + + /// landscapeLeft back == landscapeRight front, and vice-versa, + /// confirming the front/back landscape values are exactly swapped. + func test_computeUprightRotation_landscapeValues_areExactlySwapped() { + camera.deviceOrientation = .landscapeLeft + camera.isFrontFacingCamera = false + let backLeft = camera.computeUprightRotation() + + camera.deviceOrientation = .landscapeRight + camera.isFrontFacingCamera = true + let frontRight = camera.computeUprightRotation() + + XCTAssertEqual(backLeft, frontRight, + "Back landscapeLeft and front landscapeRight must be equal (both 0°)") + + camera.deviceOrientation = .landscapeRight + camera.isFrontFacingCamera = false + let backRight = camera.computeUprightRotation() + + camera.deviceOrientation = .landscapeLeft + camera.isFrontFacingCamera = true + let frontLeft = camera.computeUprightRotation() + + XCTAssertEqual(backRight, frontLeft, + "Back landscapeRight and front landscapeLeft must be equal (both 180°)") + } + + // MARK: - Result is always a canonical rotation angle + + /// For every orientation × camera-type combination the result must be + /// one of the four canonical values {0, 90, 180, 270}. + func test_computeUprightRotation_allCombinations_resultIsCanonical() { + let orientations: [UIDeviceOrientation] = [ + .portrait, .portraitUpsideDown, + .landscapeLeft, .landscapeRight, + .faceUp, .faceDown, .unknown + ] + let validAngles: Set = [0, 90, 180, 270] + + for isFront in [false, true] { + camera.isFrontFacingCamera = isFront + for orientation in orientations { + camera.deviceOrientation = orientation + let result = camera.computeUprightRotation() + XCTAssertTrue(validAngles.contains(result), + "Non-canonical rotation \(result)° for orientation \(orientation.rawValue), " + + "isFront=\(isFront)") + } + } + } + + // MARK: - Idempotency and liveness + + /// Calling the method twice with the same state must return the same value, + /// confirming that computeUprightRotation() is read-only and side-effect-free. + func test_computeUprightRotation_consecutiveCalls_returnSameValue() { + camera.isFrontFacingCamera = false + camera.deviceOrientation = .portrait + let first = camera.computeUprightRotation() + let second = camera.computeUprightRotation() + XCTAssertEqual(first, second, + "Consecutive calls with identical state must return the same value") + } + + /// When deviceOrientation changes, the very next call must reflect the new value, + /// confirming the live orientation is read on every invocation. + func test_computeUprightRotation_updatesWhenOrientationChanges() { + camera.isFrontFacingCamera = false + + camera.deviceOrientation = .portrait + let portrait = camera.computeUprightRotation() // 90 + + camera.deviceOrientation = .landscapeLeft + let landscape = camera.computeUprightRotation() // 0 + + XCTAssertNotEqual(portrait, landscape, + "Result must update immediately when deviceOrientation changes") + XCTAssertEqual(portrait, 90) + XCTAssertEqual(landscape, 0) + } + + /// Swapping camera type must immediately change the result for landscape + /// orientations, confirming isFrontFacingCamera is read on every invocation. + func test_computeUprightRotation_updatesWhenCameraTypeChanges() { + camera.deviceOrientation = .landscapeLeft + + camera.isFrontFacingCamera = false + let back = camera.computeUprightRotation() // 0 + + camera.isFrontFacingCamera = true + let front = camera.computeUprightRotation() // 180 + + XCTAssertNotEqual(back, front, + "Result must update immediately when isFrontFacingCamera changes") + } +} diff --git a/ios/test/unit/NativeCameraTests.swift b/ios/test/unit/NativeCameraTests.swift index fd68072..5f28d41 100644 --- a/ios/test/unit/NativeCameraTests.swift +++ b/ios/test/unit/NativeCameraTests.swift @@ -228,13 +228,13 @@ final class NativeCameraMirrorTests: XCTestCase { func test_mirror_neitherAxis_returnsOriginalData() { let result = camera.mirrorData(PixelBufferFixture.gray2x2, w: 2, h: 2, gray: true, - horizontal: false, vertical: false) + horizontal: false, vertical: false) XCTAssertEqual([UInt8](result.data), [UInt8](PixelBufferFixture.gray2x2)) } func test_mirror_neitherAxis_preservesDimensions() { let result = camera.mirrorData(PixelBufferFixture.gray2x2, w: 2, h: 2, gray: true, - horizontal: false, vertical: false) + horizontal: false, vertical: false) XCTAssertEqual(result.w, 2) XCTAssertEqual(result.h, 2) } @@ -245,22 +245,22 @@ final class NativeCameraMirrorTests: XCTestCase { // [ 10 | 20 ] → [ 20 | 10 ] // [ 30 | 40 ] → [ 40 | 30 ] let result = camera.mirrorData(PixelBufferFixture.gray2x2, w: 2, h: 2, gray: true, - horizontal: true, vertical: false) + horizontal: true, vertical: false) XCTAssertEqual([UInt8](result.data), [20, 10, 40, 30]) } func test_mirror_gray_horizontal_preservesDimensions() { let result = camera.mirrorData(PixelBufferFixture.gray2x2, w: 2, h: 2, gray: true, - horizontal: true, vertical: false) + horizontal: true, vertical: false) XCTAssertEqual(result.w, 2) XCTAssertEqual(result.h, 2) } func test_mirror_gray_horizontal_appliedTwice_recoversOriginal() { let once = camera.mirrorData(PixelBufferFixture.gray2x2, w: 2, h: 2, gray: true, - horizontal: true, vertical: false) + horizontal: true, vertical: false) let twice = camera.mirrorData(once.data, w: 2, h: 2, gray: true, - horizontal: true, vertical: false) + horizontal: true, vertical: false) XCTAssertEqual([UInt8](twice.data), [UInt8](PixelBufferFixture.gray2x2)) } @@ -270,15 +270,15 @@ final class NativeCameraMirrorTests: XCTestCase { // [ 10 | 20 ] → [ 30 | 40 ] // [ 30 | 40 ] → [ 10 | 20 ] let result = camera.mirrorData(PixelBufferFixture.gray2x2, w: 2, h: 2, gray: true, - horizontal: false, vertical: true) + horizontal: false, vertical: true) XCTAssertEqual([UInt8](result.data), [30, 40, 10, 20]) } func test_mirror_gray_vertical_appliedTwice_recoversOriginal() { let once = camera.mirrorData(PixelBufferFixture.gray2x2, w: 2, h: 2, gray: true, - horizontal: false, vertical: true) + horizontal: false, vertical: true) let twice = camera.mirrorData(once.data, w: 2, h: 2, gray: true, - horizontal: false, vertical: true) + horizontal: false, vertical: true) XCTAssertEqual([UInt8](twice.data), [UInt8](PixelBufferFixture.gray2x2)) } @@ -287,7 +287,7 @@ final class NativeCameraMirrorTests: XCTestCase { func test_mirror_gray_bothAxes_equals180Rotation() { // Mirroring both H and V is equivalent to a 180° rotation. let mirrored = camera.mirrorData(PixelBufferFixture.gray2x2, w: 2, h: 2, gray: true, - horizontal: true, vertical: true) + horizontal: true, vertical: true) let rotated = camera.rotateData(PixelBufferFixture.gray2x2, w: 2, h: 2, degrees: 180, gray: true) XCTAssertEqual([UInt8](mirrored.data), [UInt8](rotated.data)) } @@ -296,7 +296,7 @@ final class NativeCameraMirrorTests: XCTestCase { func test_mirror_rgba_horizontal_preservesAlpha() { let result = camera.mirrorData(PixelBufferFixture.rgba2x2, w: 2, h: 2, gray: false, - horizontal: true, vertical: false) + horizontal: true, vertical: false) let bytes = [UInt8](result.data) for i in stride(from: 3, to: bytes.count, by: 4) { XCTAssertEqual(bytes[i], 255, "Alpha corrupted at pixel \(i/4)") @@ -528,7 +528,7 @@ final class NativeCameraLifecycleTests: XCTestCase { let valid = Set([0, 90, 180, 270]) for cam in cameras { XCTAssertTrue(valid.contains(cam.sensorOrientation), - "sensorOrientation \(cam.sensorOrientation) is not a canonical angle (0/90/180/270)") + "sensorOrientation \(cam.sensorOrientation) is not a canonical angle (0/90/180/270)") } } @@ -542,434 +542,3 @@ final class NativeCameraLifecycleTests: XCTestCase { XCTAssertNil(camera.onPermissionResult) } } - -// MARK: - NativeCamera Auto-Upright Tests - -/// Tests for `computeUprightRotation()` and the supporting state fields. -/// -/// ## Strategy -/// -/// `computeUprightRotation()` is `internal` and therefore accessible via -/// `@testable import`. Its two inputs — `isFrontFacingCamera` and -/// `deviceOrientation` — are `internal` (promoted from `private` specifically -/// to enable this test class) so they can be injected directly. This lets -/// every orientation × camera-type combination be exercised without requiring -/// real hardware or a live capture session. -/// -/// ## Rotation table under test -/// -/// | `UIDeviceOrientation` | Back camera | Front camera | -/// |-----------------------|-------------|--------------| -/// | `.portrait` | 90° | 90° | -/// | `.portraitUpsideDown` | 270° | 270° | -/// | `.landscapeLeft` | 0° | 180° | -/// | `.landscapeRight` | 180° | 0° | -/// | `.faceUp` | 90° (fallback) | 90° (fallback) | -/// | `.faceDown` | 90° (fallback) | 90° (fallback) | -/// | `.unknown` | 90° (fallback) | 90° (fallback) | -final class NativeCameraAutoUprightTests: XCTestCase { - - private var camera: NativeCamera! - - override func setUp() { - super.setUp() - camera = NativeCamera() - } - - override func tearDown() { - camera = nil - super.tearDown() - } - - // MARK: - State defaults - - func test_isFrontFacingCamera_defaultsFalse() { - XCTAssertFalse(camera.isFrontFacingCamera, - "isFrontFacingCamera must default to false (back camera assumption)") - } - - func test_deviceOrientation_defaultsPortrait() { - XCTAssertEqual(camera.deviceOrientation, .portrait, - "deviceOrientation must default to .portrait so the first frame is always valid") - } - - // MARK: - Back camera — all four primary orientations - - /// Back, portrait: sensor is landscape; device is upright → rotate 90° CW. - func test_computeUprightRotation_backCamera_portrait_returns90() { - camera.isFrontFacingCamera = false - camera.deviceOrientation = .portrait - XCTAssertEqual(camera.computeUprightRotation(), 90) - } - - /// Back, portrait upside-down: sensor landscape, device inverted → rotate 270° CW. - func test_computeUprightRotation_backCamera_portraitUpsideDown_returns270() { - camera.isFrontFacingCamera = false - camera.deviceOrientation = .portraitUpsideDown - XCTAssertEqual(camera.computeUprightRotation(), 270) - } - - /// Back, landscapeLeft (top points left, home button right): - /// sensor already aligned with the display → 0° needed. - func test_computeUprightRotation_backCamera_landscapeLeft_returns0() { - camera.isFrontFacingCamera = false - camera.deviceOrientation = .landscapeLeft - XCTAssertEqual(camera.computeUprightRotation(), 0) - } - - /// Back, landscapeRight (top points right, home button left): - /// sensor inverted relative to the display → rotate 180°. - func test_computeUprightRotation_backCamera_landscapeRight_returns180() { - camera.isFrontFacingCamera = false - camera.deviceOrientation = .landscapeRight - XCTAssertEqual(camera.computeUprightRotation(), 180) - } - - // MARK: - Front camera — all four primary orientations - - /// Front, portrait: front sensor is horizontally mirrored but still needs - /// 90° CW to be upright — same as back camera for portrait. - func test_computeUprightRotation_frontCamera_portrait_returns90() { - camera.isFrontFacingCamera = true - camera.deviceOrientation = .portrait - XCTAssertEqual(camera.computeUprightRotation(), 90) - } - - /// Front, portrait upside-down: 270° — same as back camera for this axis. - func test_computeUprightRotation_frontCamera_portraitUpsideDown_returns270() { - camera.isFrontFacingCamera = true - camera.deviceOrientation = .portraitUpsideDown - XCTAssertEqual(camera.computeUprightRotation(), 270) - } - - /// Front, landscapeLeft: because the front sensor is horizontally mirrored, - /// the landscape cases are swapped compared to the back camera → 180°. - func test_computeUprightRotation_frontCamera_landscapeLeft_returns180() { - camera.isFrontFacingCamera = true - camera.deviceOrientation = .landscapeLeft - XCTAssertEqual(camera.computeUprightRotation(), 180) - } - - /// Front, landscapeRight: mirrored from back → 0°. - func test_computeUprightRotation_frontCamera_landscapeRight_returns0() { - camera.isFrontFacingCamera = true - camera.deviceOrientation = .landscapeRight - XCTAssertEqual(camera.computeUprightRotation(), 0) - } - - // MARK: - Fallback orientations → portrait default (90°) - - func test_computeUprightRotation_backCamera_faceUp_returns90() { - camera.isFrontFacingCamera = false - camera.deviceOrientation = .faceUp - XCTAssertEqual(camera.computeUprightRotation(), 90, - ".faceUp must fall through to the portrait default of 90°") - } - - func test_computeUprightRotation_backCamera_faceDown_returns90() { - camera.isFrontFacingCamera = false - camera.deviceOrientation = .faceDown - XCTAssertEqual(camera.computeUprightRotation(), 90, - ".faceDown must fall through to the portrait default of 90°") - } - - func test_computeUprightRotation_backCamera_unknown_returns90() { - camera.isFrontFacingCamera = false - camera.deviceOrientation = .unknown - XCTAssertEqual(camera.computeUprightRotation(), 90, - ".unknown must fall through to the portrait default of 90°") - } - - func test_computeUprightRotation_frontCamera_faceUp_returns90() { - camera.isFrontFacingCamera = true - camera.deviceOrientation = .faceUp - XCTAssertEqual(camera.computeUprightRotation(), 90) - } - - func test_computeUprightRotation_frontCamera_faceDown_returns90() { - camera.isFrontFacingCamera = true - camera.deviceOrientation = .faceDown - XCTAssertEqual(camera.computeUprightRotation(), 90) - } - - func test_computeUprightRotation_frontCamera_unknown_returns90() { - camera.isFrontFacingCamera = true - camera.deviceOrientation = .unknown - XCTAssertEqual(camera.computeUprightRotation(), 90) - } - - // MARK: - Portrait orientations are the same for both camera types - - /// Portrait cases do not involve the horizontal-mirror difference between - /// front and back sensors, so both cameras must return the same value. - func test_computeUprightRotation_portrait_sameForBothCameraTypes() { - camera.deviceOrientation = .portrait - camera.isFrontFacingCamera = false - let back = camera.computeUprightRotation() - camera.isFrontFacingCamera = true - let front = camera.computeUprightRotation() - XCTAssertEqual(back, front, "Both cameras must agree on portrait rotation") - } - - func test_computeUprightRotation_portraitUpsideDown_sameForBothCameraTypes() { - camera.deviceOrientation = .portraitUpsideDown - camera.isFrontFacingCamera = false - let back = camera.computeUprightRotation() - camera.isFrontFacingCamera = true - let front = camera.computeUprightRotation() - XCTAssertEqual(back, front, "Both cameras must agree on portrait-upside-down rotation") - } - - // MARK: - Landscape orientations differ between front and back cameras - - func test_computeUprightRotation_landscapeLeft_frontAndBackDiffer() { - camera.deviceOrientation = .landscapeLeft - camera.isFrontFacingCamera = false - let back = camera.computeUprightRotation() // 0 - camera.isFrontFacingCamera = true - let front = camera.computeUprightRotation() // 180 - XCTAssertNotEqual(back, front, - "landscapeLeft rotation must differ between front and back cameras") - } - - func test_computeUprightRotation_landscapeRight_frontAndBackDiffer() { - camera.deviceOrientation = .landscapeRight - camera.isFrontFacingCamera = false - let back = camera.computeUprightRotation() // 180 - camera.isFrontFacingCamera = true - let front = camera.computeUprightRotation() // 0 - XCTAssertNotEqual(back, front, - "landscapeRight rotation must differ between front and back cameras") - } - - /// landscapeLeft back == landscapeRight front, and vice-versa, - /// confirming the front/back landscape values are exactly swapped. - func test_computeUprightRotation_landscapeValues_areExactlySwapped() { - camera.deviceOrientation = .landscapeLeft - camera.isFrontFacingCamera = false - let backLeft = camera.computeUprightRotation() - - camera.deviceOrientation = .landscapeRight - camera.isFrontFacingCamera = true - let frontRight = camera.computeUprightRotation() - - XCTAssertEqual(backLeft, frontRight, - "Back landscapeLeft and front landscapeRight must be equal (both 0°)") - - camera.deviceOrientation = .landscapeRight - camera.isFrontFacingCamera = false - let backRight = camera.computeUprightRotation() - - camera.deviceOrientation = .landscapeLeft - camera.isFrontFacingCamera = true - let frontLeft = camera.computeUprightRotation() - - XCTAssertEqual(backRight, frontLeft, - "Back landscapeRight and front landscapeLeft must be equal (both 180°)") - } - - // MARK: - Result is always a canonical rotation angle - - /// For every orientation × camera-type combination the result must be - /// one of the four canonical values {0, 90, 180, 270}. - func test_computeUprightRotation_allCombinations_resultIsCanonical() { - let orientations: [UIDeviceOrientation] = [ - .portrait, .portraitUpsideDown, - .landscapeLeft, .landscapeRight, - .faceUp, .faceDown, .unknown - ] - let validAngles: Set = [0, 90, 180, 270] - - for isFront in [false, true] { - camera.isFrontFacingCamera = isFront - for orientation in orientations { - camera.deviceOrientation = orientation - let result = camera.computeUprightRotation() - XCTAssertTrue(validAngles.contains(result), - "Non-canonical rotation \(result)° for orientation \(orientation.rawValue), " - + "isFront=\(isFront)") - } - } - } - - // MARK: - Idempotency and liveness - - /// Calling the method twice with the same state must return the same value, - /// confirming that computeUprightRotation() is read-only and side-effect-free. - func test_computeUprightRotation_consecutiveCalls_returnSameValue() { - camera.isFrontFacingCamera = false - camera.deviceOrientation = .portrait - let first = camera.computeUprightRotation() - let second = camera.computeUprightRotation() - XCTAssertEqual(first, second, - "Consecutive calls with identical state must return the same value") - } - - /// When deviceOrientation changes, the very next call must reflect the new value, - /// confirming the live orientation is read on every invocation. - func test_computeUprightRotation_updatesWhenOrientationChanges() { - camera.isFrontFacingCamera = false - - camera.deviceOrientation = .portrait - let portrait = camera.computeUprightRotation() // 90 - - camera.deviceOrientation = .landscapeLeft - let landscape = camera.computeUprightRotation() // 0 - - XCTAssertNotEqual(portrait, landscape, - "Result must update immediately when deviceOrientation changes") - XCTAssertEqual(portrait, 90) - XCTAssertEqual(landscape, 0) - } - - /// Swapping camera type must immediately change the result for landscape - /// orientations, confirming isFrontFacingCamera is read on every invocation. - func test_computeUprightRotation_updatesWhenCameraTypeChanges() { - camera.deviceOrientation = .landscapeLeft - - camera.isFrontFacingCamera = false - let back = camera.computeUprightRotation() // 0 - - camera.isFrontFacingCamera = true - let front = camera.computeUprightRotation() // 180 - - XCTAssertNotEqual(back, front, - "Result must update immediately when isFrontFacingCamera changes") - } -} - -// MARK: - CameraInfo Tests - -/// Tests for the `CameraInfo` model, focusing on the `sensorOrientation` property. -/// -/// `CameraInfo.init` requires a real `AVCaptureDevice`; tests that need one are -/// guarded by `XCTSkip` so they are silently skipped on a Simulator host with -/// no physical cameras attached. -final class CameraInfoTests: XCTestCase { - - // MARK: - sensorOrientation stored at init - - func test_sensorOrientation_90_isStoredAtInit() throws { - let dev = AVCaptureDevice.default(for: .video) - try XCTSkipIf(dev == nil, "No camera available on this host") - let info = CameraInfo(id: "cam0", device: dev!, outputSizes: [], sensorOrientation: 90) - XCTAssertEqual(info.sensorOrientation, 90) - } - - func test_sensorOrientation_0_isStoredAtInit() throws { - let dev = AVCaptureDevice.default(for: .video) - try XCTSkipIf(dev == nil, "No camera available on this host") - let info = CameraInfo(id: "cam0", device: dev!, outputSizes: [], sensorOrientation: 0) - XCTAssertEqual(info.sensorOrientation, 0) - } - - func test_sensorOrientation_180_isStoredAtInit() throws { - let dev = AVCaptureDevice.default(for: .video) - try XCTSkipIf(dev == nil, "No camera available on this host") - let info = CameraInfo(id: "cam0", device: dev!, outputSizes: [], sensorOrientation: 180) - XCTAssertEqual(info.sensorOrientation, 180) - } - - func test_sensorOrientation_270_isStoredAtInit() throws { - let dev = AVCaptureDevice.default(for: .video) - try XCTSkipIf(dev == nil, "No camera available on this host") - let info = CameraInfo(id: "cam0", device: dev!, outputSizes: [], sensorOrientation: 270) - XCTAssertEqual(info.sensorOrientation, 270) - } - - // MARK: - sensorOrientation does not interfere with other stored properties - - func test_sensorOrientation_doesNotAffectId() throws { - let dev = AVCaptureDevice.default(for: .video) - try XCTSkipIf(dev == nil, "No camera available on this host") - let info = CameraInfo(id: "unique-id", device: dev!, outputSizes: [], sensorOrientation: 90) - XCTAssertEqual(info.id, "unique-id") - } - - func test_sensorOrientation_doesNotAffectOutputSizesCount() throws { - let dev = AVCaptureDevice.default(for: .video) - try XCTSkipIf(dev == nil, "No camera available on this host") - let sizes = [FrameSize(width: 1280, height: 720), FrameSize(width: 640, height: 480)] - let info = CameraInfo(id: "cam0", device: dev!, outputSizes: sizes, sensorOrientation: 90) - XCTAssertEqual(info.outputSizes.count, 2) - } -} - -// MARK: - NativeCamera.deriveSensorOrientation Tests - -/// Tests for `NativeCamera.deriveSensorOrientation(from:)`. -/// -/// The helper is `internal static`, making it accessible via `@testable import`. -/// Because it requires a real `AVCaptureDevice`, every test that constructs one -/// is guarded by `XCTSkip` for Simulator hosts with no cameras. -/// -/// ## What is tested here -/// -/// | Scenario | Expected result | -/// |---|---| -/// | Device has landscape-native formats (width ≥ height) | 90° | -/// | Device has no formats | 90° (safe default) | -/// | All cameras returned by getCameras() | value in {0, 90, 180, 270} | -final class NativeCameraDeriveSensorOrientationTests: XCTestCase { - - // MARK: - Default when no formats are available - - /// The helper must never throw and must return a sensible default when the - /// device exposes no formats. We cannot construct a formatless device in - /// a unit test without private API, so this scenario is verified by checking - /// that `deriveSensorOrientation` on any real device returns a canonical value. - func test_deriveSensorOrientation_realDevice_returnsCanonicalValue() throws { - let dev = AVCaptureDevice.default(for: .video) - try XCTSkipIf(dev == nil, "No camera available on this host") - let result = NativeCamera.deriveSensorOrientation(from: dev!) - XCTAssertTrue([0, 90, 180, 270].contains(result), - "deriveSensorOrientation returned non-canonical value \(result)") - } - - // MARK: - iPhone built-in back camera is landscape-native → 90° - - func test_deriveSensorOrientation_backCamera_returns90() throws { - guard let dev = AVCaptureDevice.default(.builtInWideAngleCamera, - for: .video, position: .back) else { - throw XCTSkip("No back camera available on this host") - } - // All iPhone back cameras capture in landscape natively. - XCTAssertEqual(NativeCamera.deriveSensorOrientation(from: dev), 90, - "iPhone back camera should report sensorOrientation 90") - } - - func test_deriveSensorOrientation_frontCamera_returnsCanonicalValue() throws { - guard let dev = AVCaptureDevice.default(.builtInWideAngleCamera, - for: .video, position: .front) else { - throw XCTSkip("No front camera available on this host") - } - let result = NativeCamera.deriveSensorOrientation(from: dev) - XCTAssertTrue([0, 90, 180, 270].contains(result), - "Front camera sensorOrientation \(result) is not canonical") - } - - // MARK: - getCameras() integration: sensorOrientation propagates end-to-end - - /// When `getCameras()` is called on a host that has at least one camera, - /// every returned `CameraInfo` must carry a `sensorOrientation` that is a - /// member of {0, 90, 180, 270}. - func test_getCameras_sensorOrientation_isCanonicalForAllCameras() { - let cameras = NativeCamera().getCameras() - let valid = Set([0, 90, 180, 270]) - for cam in cameras { - XCTAssertTrue(valid.contains(cam.sensorOrientation), - "Camera '\(cam.id)' sensorOrientation \(cam.sensorOrientation) is not canonical") - } - } - - /// Confirms that `getCameras()` propagates `sensorOrientation` into `CameraInfo` - /// by checking that the property is non-negative for every camera. - /// (A value of 0 is valid for landscape-native iPads; negative would be a bug.) - func test_getCameras_sensorOrientation_isNonNegative() { - let cameras = NativeCamera().getCameras() - for cam in cameras { - XCTAssertGreaterThanOrEqual(cam.sensorOrientation, 0, - "Camera '\(cam.id)' has negative sensorOrientation") - } - } -}