@chipmobilesdk/rn-camera 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,249 +1,256 @@
1
- /**
2
- * Deterministic in-memory camera for tests (FR-041, SC-010).
3
- *
4
- * These are real implementations of the same published ports the production
5
- * adapters implement, not a parallel mock — so a contract change breaks them at
6
- * compile time and they cannot drift. Nothing here touches a native module, the
7
- * filesystem, or a permission API.
8
- */
9
-
10
- import {
11
- known,
12
- unavailable,
13
- type CameraPermissionState,
14
- type CameraPosition,
15
- type CapabilityProfile,
16
- type CapturedImage,
17
- type CaptureRequest,
18
- } from '../config/types';
19
- import { CameraError, type CameraErrorCode } from '../diagnostics/errors';
20
- import type { CameraEngine } from '../native/cameraEngine';
21
- import type { PermissionPort } from '../permissions/permissionPort';
22
- import {
23
- createArtifactStore,
24
- type ArtifactStore,
25
- type FileSystemBridge,
26
- } from '../storage/artifactStore';
27
- import type { AppStatus } from '../core/controller';
28
-
29
- export interface MemoryFileSystem extends FileSystemBridge {
30
- /** Seeds a file with an explicit creation time, for time-to-live tests. */
31
- seed(path: string, createdAtMs: number): void;
32
- /** Every path currently present. */
33
- paths(): readonly string[];
34
- }
35
-
36
- export function createMemoryFileSystem(root = '/cache'): MemoryFileSystem {
37
- const files = new Map<string, number>();
38
- const directories = new Set<string>();
39
-
40
- return {
41
- cacheDirectory: () => root,
42
- async exists(path) {
43
- return files.has(path);
44
- },
45
- async unlink(path) {
46
- files.delete(path);
47
- },
48
- async list(directory) {
49
- const prefix = `${directory.replace(/\/$/, '')}/`;
50
- return Array.from(files.keys()).filter(path => path.startsWith(prefix));
51
- },
52
- async createdAt(path) {
53
- return files.get(path) ?? Date.now();
54
- },
55
- async ensureDirectory(directory) {
56
- directories.add(directory);
57
- },
58
- seed(path, createdAtMs) {
59
- files.set(path, createdAtMs);
60
- },
61
- paths: () => Array.from(files.keys()),
62
- };
63
- }
64
-
65
- export const DEFAULT_TEST_CAPABILITIES: CapabilityProfile = {
66
- availablePositions: ['back', 'front'],
67
- hasFlash: true,
68
- hasTorch: true,
69
- supportsOrientationChange: true,
70
- hasCameraHardware: true,
71
- };
72
-
73
- export interface TestCameraConfig {
74
- readonly permission?: CameraPermissionState;
75
- readonly capabilities?: Partial<CapabilityProfile>;
76
- readonly captureResult?: Partial<CapturedImage>;
77
- readonly captureError?: CameraErrorCode;
78
- readonly startError?: CameraErrorCode;
79
- readonly cacheRoot?: string;
80
- }
81
-
82
- export interface TestCameraHandles {
83
- readonly engine: CameraEngine;
84
- readonly permissions: PermissionPort;
85
- readonly store: ArtifactStore;
86
- readonly fs: MemoryFileSystem;
87
- /** Drives the controller's app foreground/background handling. */
88
- subscribeToAppState(listener: (status: AppStatus) => void): () => void;
89
- setAppState(status: AppStatus): void;
90
- /** Simulates a Settings-level permission change while running (FR-007). */
91
- setPermission(state: CameraPermissionState): void;
92
- /** Simulates an OS preemption such as an incoming call. */
93
- emitInterruption(): void;
94
- setCaptureError(code: CameraErrorCode | null): void;
95
- setStartError(code: CameraErrorCode | null): void;
96
- /** Every capture the engine produced, in order. */
97
- captures(): readonly CapturedImage[];
98
- torchState(): boolean;
99
- startCount(): number;
100
- stopCount(): number;
101
- }
102
-
103
- export function createTestCamera(config: TestCameraConfig = {}): TestCameraHandles {
104
- const fs = createMemoryFileSystem(config.cacheRoot ?? '/cache');
105
- const store = createArtifactStore(fs);
106
- const capabilities: CapabilityProfile = {
107
- ...DEFAULT_TEST_CAPABILITIES,
108
- ...config.capabilities,
109
- };
110
-
111
- let permission: CameraPermissionState = config.permission ?? 'undetermined';
112
- let captureError: CameraErrorCode | null = config.captureError ?? null;
113
- let startError: CameraErrorCode | null = config.startError ?? null;
114
- let torch = false;
115
- let starts = 0;
116
- let stops = 0;
117
- let sequence = 0;
118
-
119
- const produced: CapturedImage[] = [];
120
- const permissionListeners = new Set<(state: CameraPermissionState) => void>();
121
- const appStateListeners = new Set<(status: AppStatus) => void>();
122
- const interruptionListeners = new Set<() => void>();
123
-
124
- const permissions: PermissionPort = {
125
- async getStatus() {
126
- return permission;
127
- },
128
- async request() {
129
- // Mirrors the real adapter: never re-prompt after a permanent denial.
130
- if (permission === 'deniedPermanently') {
131
- return permission;
132
- }
133
- permission = permission === 'undetermined' || permission === 'denied' ? 'granted' : permission;
134
- for (const listener of permissionListeners) {
135
- listener(permission);
136
- }
137
- return permission;
138
- },
139
- subscribe(listener) {
140
- permissionListeners.add(listener);
141
- return () => {
142
- permissionListeners.delete(listener);
143
- };
144
- },
145
- };
146
-
147
- const engine: CameraEngine = {
148
- async start() {
149
- starts += 1;
150
- if (startError) {
151
- throw new CameraError(startError);
152
- }
153
- if (!capabilities.hasCameraHardware) {
154
- throw new CameraError('cameraUnavailable');
155
- }
156
- },
157
-
158
- async stop() {
159
- stops += 1;
160
- torch = false;
161
- },
162
-
163
- async capture(request: CaptureRequest) {
164
- if (captureError) {
165
- throw new CameraError(captureError);
166
- }
167
- sequence += 1;
168
- const position: CameraPosition = request.cameraPosition ?? 'back';
169
- const uri =
170
- request.destination ?? `${store.temporaryDirectory()}/capture-${sequence}.jpg`;
171
- fs.seed(uri, Date.now());
172
-
173
- const image: CapturedImage = {
174
- uri,
175
- mimeType: 'image/jpeg',
176
- width: 1920,
177
- height: 1080,
178
- timestamp: Date.now(),
179
- cameraPosition: position,
180
- ownership: request.destination ? 'appOwned' : 'packageTemporary',
181
- effectiveQuality: request.quality ?? 0.9,
182
- effectiveMaxDimension:
183
- request.maxDimension === undefined ? unavailable() : known(request.maxDimension),
184
- fileSize: known(1024),
185
- orientation: known(0),
186
- ...config.captureResult,
187
- };
188
- produced.push(image);
189
- return image;
190
- },
191
-
192
- async setTorch(enabled: boolean) {
193
- torch = enabled;
194
- },
195
-
196
- async getCapabilities() {
197
- return capabilities;
198
- },
199
- };
200
-
201
- return {
202
- engine,
203
- permissions,
204
- store,
205
- fs,
206
-
207
- subscribeToAppState(listener) {
208
- appStateListeners.add(listener);
209
- return () => {
210
- appStateListeners.delete(listener);
211
- };
212
- },
213
-
214
- setAppState(status) {
215
- for (const listener of appStateListeners) {
216
- listener(status);
217
- }
218
- },
219
-
220
- setPermission(state) {
221
- permission = state;
222
- for (const listener of permissionListeners) {
223
- listener(state);
224
- }
225
- },
226
-
227
- emitInterruption() {
228
- for (const listener of interruptionListeners) {
229
- listener();
230
- }
231
- // An interruption behaves like the OS taking the camera away: the next
232
- // start attempt fails until the app clears it.
233
- startError = 'interrupted';
234
- },
235
-
236
- setCaptureError(code) {
237
- captureError = code;
238
- },
239
-
240
- setStartError(code) {
241
- startError = code;
242
- },
243
-
244
- captures: () => produced,
245
- torchState: () => torch,
246
- startCount: () => starts,
247
- stopCount: () => stops,
248
- };
249
- }
1
+ /**
2
+ * Deterministic in-memory camera for tests (FR-041, SC-010).
3
+ *
4
+ * These are real implementations of the same published ports the production
5
+ * adapters implement, not a parallel mock — so a contract change breaks them at
6
+ * compile time and they cannot drift. Nothing here touches a native module, the
7
+ * filesystem, or a permission API.
8
+ */
9
+
10
+ import {
11
+ known,
12
+ unavailable,
13
+ type CameraPermissionState,
14
+ type CameraPosition,
15
+ type CapabilityProfile,
16
+ type CapturedImage,
17
+ type CaptureRequest,
18
+ } from '../config/types';
19
+ import { CameraError, type CameraErrorCode } from '../diagnostics/errors';
20
+ import type { CameraEngine } from '../native/cameraEngine';
21
+ import type { PermissionPort } from '../permissions/permissionPort';
22
+ import {
23
+ createArtifactStore,
24
+ type ArtifactStore,
25
+ type FileSystemBridge,
26
+ } from '../storage/artifactStore';
27
+ import type { AppStatus } from '../core/controller';
28
+
29
+ export interface MemoryFileSystem extends FileSystemBridge {
30
+ /** Seeds a file with an explicit creation time, for time-to-live tests. */
31
+ seed(path: string, createdAtMs: number): void;
32
+ /** Every path currently present. */
33
+ paths(): readonly string[];
34
+ }
35
+
36
+ export function createMemoryFileSystem(root = '/cache'): MemoryFileSystem {
37
+ const files = new Map<string, number>();
38
+ const directories = new Set<string>();
39
+
40
+ return {
41
+ cacheDirectory: () => root,
42
+ async exists(path) {
43
+ return files.has(path);
44
+ },
45
+ async unlink(path) {
46
+ files.delete(path);
47
+ },
48
+ async list(directory) {
49
+ const prefix = `${directory.replace(/\/$/, '')}/`;
50
+ return Array.from(files.keys()).filter(path => path.startsWith(prefix));
51
+ },
52
+ async createdAt(path) {
53
+ return files.get(path) ?? Date.now();
54
+ },
55
+ async ensureDirectory(directory) {
56
+ directories.add(directory);
57
+ },
58
+ async move(from, to) {
59
+ if (!files.has(from)) {
60
+ throw new Error('No such file');
61
+ }
62
+ files.set(to, files.get(from) ?? Date.now());
63
+ files.delete(from);
64
+ },
65
+ seed(path, createdAtMs) {
66
+ files.set(path, createdAtMs);
67
+ },
68
+ paths: () => Array.from(files.keys()),
69
+ };
70
+ }
71
+
72
+ export const DEFAULT_TEST_CAPABILITIES: CapabilityProfile = {
73
+ availablePositions: ['back', 'front'],
74
+ hasFlash: true,
75
+ hasTorch: true,
76
+ supportsOrientationChange: true,
77
+ hasCameraHardware: true,
78
+ };
79
+
80
+ export interface TestCameraConfig {
81
+ readonly permission?: CameraPermissionState;
82
+ readonly capabilities?: Partial<CapabilityProfile>;
83
+ readonly captureResult?: Partial<CapturedImage>;
84
+ readonly captureError?: CameraErrorCode;
85
+ readonly startError?: CameraErrorCode;
86
+ readonly cacheRoot?: string;
87
+ }
88
+
89
+ export interface TestCameraHandles {
90
+ readonly engine: CameraEngine;
91
+ readonly permissions: PermissionPort;
92
+ readonly store: ArtifactStore;
93
+ readonly fs: MemoryFileSystem;
94
+ /** Drives the controller's app foreground/background handling. */
95
+ subscribeToAppState(listener: (status: AppStatus) => void): () => void;
96
+ setAppState(status: AppStatus): void;
97
+ /** Simulates a Settings-level permission change while running (FR-007). */
98
+ setPermission(state: CameraPermissionState): void;
99
+ /** Simulates an OS preemption such as an incoming call. */
100
+ emitInterruption(): void;
101
+ setCaptureError(code: CameraErrorCode | null): void;
102
+ setStartError(code: CameraErrorCode | null): void;
103
+ /** Every capture the engine produced, in order. */
104
+ captures(): readonly CapturedImage[];
105
+ torchState(): boolean;
106
+ startCount(): number;
107
+ stopCount(): number;
108
+ }
109
+
110
+ export function createTestCamera(config: TestCameraConfig = {}): TestCameraHandles {
111
+ const fs = createMemoryFileSystem(config.cacheRoot ?? '/cache');
112
+ const store = createArtifactStore(fs);
113
+ const capabilities: CapabilityProfile = {
114
+ ...DEFAULT_TEST_CAPABILITIES,
115
+ ...config.capabilities,
116
+ };
117
+
118
+ let permission: CameraPermissionState = config.permission ?? 'undetermined';
119
+ let captureError: CameraErrorCode | null = config.captureError ?? null;
120
+ let startError: CameraErrorCode | null = config.startError ?? null;
121
+ let torch = false;
122
+ let starts = 0;
123
+ let stops = 0;
124
+ let sequence = 0;
125
+
126
+ const produced: CapturedImage[] = [];
127
+ const permissionListeners = new Set<(state: CameraPermissionState) => void>();
128
+ const appStateListeners = new Set<(status: AppStatus) => void>();
129
+ const interruptionListeners = new Set<() => void>();
130
+
131
+ const permissions: PermissionPort = {
132
+ async getStatus() {
133
+ return permission;
134
+ },
135
+ async request() {
136
+ // Mirrors the real adapter: never re-prompt after a permanent denial.
137
+ if (permission === 'deniedPermanently') {
138
+ return permission;
139
+ }
140
+ permission =
141
+ permission === 'undetermined' || permission === 'denied' ? 'granted' : permission;
142
+ for (const listener of permissionListeners) {
143
+ listener(permission);
144
+ }
145
+ return permission;
146
+ },
147
+ subscribe(listener) {
148
+ permissionListeners.add(listener);
149
+ return () => {
150
+ permissionListeners.delete(listener);
151
+ };
152
+ },
153
+ };
154
+
155
+ const engine: CameraEngine = {
156
+ async start() {
157
+ starts += 1;
158
+ if (startError) {
159
+ throw new CameraError(startError);
160
+ }
161
+ if (!capabilities.hasCameraHardware) {
162
+ throw new CameraError('cameraUnavailable');
163
+ }
164
+ },
165
+
166
+ async stop() {
167
+ stops += 1;
168
+ torch = false;
169
+ },
170
+
171
+ async capture(request: CaptureRequest) {
172
+ if (captureError) {
173
+ throw new CameraError(captureError);
174
+ }
175
+ sequence += 1;
176
+ const position: CameraPosition = request.cameraPosition ?? 'back';
177
+ const uri = request.destination ?? `${store.temporaryDirectory()}/capture-${sequence}.jpg`;
178
+ fs.seed(uri, Date.now());
179
+
180
+ const image: CapturedImage = {
181
+ uri,
182
+ mimeType: 'image/jpeg',
183
+ width: 1920,
184
+ height: 1080,
185
+ timestamp: Date.now(),
186
+ cameraPosition: position,
187
+ ownership: request.destination ? 'appOwned' : 'packageTemporary',
188
+ effectiveQuality: request.quality ?? 0.9,
189
+ effectiveMaxDimension:
190
+ request.maxDimension === undefined ? unavailable() : known(request.maxDimension),
191
+ fileSize: known(1024),
192
+ orientation: known(0),
193
+ ...config.captureResult,
194
+ };
195
+ produced.push(image);
196
+ return image;
197
+ },
198
+
199
+ async setTorch(enabled: boolean) {
200
+ torch = enabled;
201
+ },
202
+
203
+ async getCapabilities() {
204
+ return capabilities;
205
+ },
206
+ };
207
+
208
+ return {
209
+ engine,
210
+ permissions,
211
+ store,
212
+ fs,
213
+
214
+ subscribeToAppState(listener) {
215
+ appStateListeners.add(listener);
216
+ return () => {
217
+ appStateListeners.delete(listener);
218
+ };
219
+ },
220
+
221
+ setAppState(status) {
222
+ for (const listener of appStateListeners) {
223
+ listener(status);
224
+ }
225
+ },
226
+
227
+ setPermission(state) {
228
+ permission = state;
229
+ for (const listener of permissionListeners) {
230
+ listener(state);
231
+ }
232
+ },
233
+
234
+ emitInterruption() {
235
+ for (const listener of interruptionListeners) {
236
+ listener();
237
+ }
238
+ // An interruption behaves like the OS taking the camera away: the next
239
+ // start attempt fails until the app clears it.
240
+ startError = 'interrupted';
241
+ },
242
+
243
+ setCaptureError(code) {
244
+ captureError = code;
245
+ },
246
+
247
+ setStartError(code) {
248
+ startError = code;
249
+ },
250
+
251
+ captures: () => produced,
252
+ torchState: () => torch,
253
+ startCount: () => starts,
254
+ stopCount: () => stops,
255
+ };
256
+ }