@chipmobilesdk/rn-notification 0.1.0
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.
- package/CHANGELOG.md +42 -0
- package/README.md +306 -0
- package/package.json +73 -0
- package/src/channel/migrate.ts +211 -0
- package/src/channel/tone-map.ts +90 -0
- package/src/contract/channel.ts +89 -0
- package/src/contract/diagnostics.ts +102 -0
- package/src/contract/errors.ts +123 -0
- package/src/contract/index.ts +10 -0
- package/src/contract/interaction.ts +62 -0
- package/src/contract/notification.ts +118 -0
- package/src/contract/permission.ts +60 -0
- package/src/contract/reconcile.ts +88 -0
- package/src/contract/recurrence.ts +123 -0
- package/src/diagnostics/burst.ts +101 -0
- package/src/diagnostics/events.ts +72 -0
- package/src/diagnostics/redact.ts +72 -0
- package/src/diagnostics/snapshot.ts +64 -0
- package/src/engine/adapter.ts +79 -0
- package/src/engine/notify-kit-engine.ts +535 -0
- package/src/index.ts +173 -0
- package/src/initialize.ts +174 -0
- package/src/interaction/index.ts +146 -0
- package/src/maintenance/cycle.ts +147 -0
- package/src/maintenance/permission-refresh.ts +41 -0
- package/src/maintenance/reanchor.ts +95 -0
- package/src/maintenance/toggle.ts +60 -0
- package/src/maintenance/verify.ts +83 -0
- package/src/maintenance/window-refresh.ts +61 -0
- package/src/permission/index.ts +183 -0
- package/src/permission/state.ts +77 -0
- package/src/push/envelope.ts +165 -0
- package/src/push/fcm-provider.ts +146 -0
- package/src/push/gate.ts +190 -0
- package/src/push/index.ts +29 -0
- package/src/push/instructions.ts +109 -0
- package/src/push/provider-adapter.ts +54 -0
- package/src/push/signout.ts +65 -0
- package/src/reconcile/chunk.ts +53 -0
- package/src/reconcile/diff.ts +145 -0
- package/src/reconcile/duplicates.ts +73 -0
- package/src/reconcile/index.ts +175 -0
- package/src/reconcile/truncate.ts +56 -0
- package/src/recurrence/clock-guard.ts +71 -0
- package/src/recurrence/expand.ts +292 -0
- package/src/recurrence/index.ts +177 -0
- package/src/schedule/anchor.ts +267 -0
- package/src/schedule/index.ts +173 -0
- package/src/schedule/partition.ts +85 -0
- package/src/schedule/resolve-moment.ts +80 -0
- package/src/schedule/validate-content.ts +111 -0
- package/src/schedule/validate-identifier.ts +114 -0
- package/src/state/caps.ts +71 -0
- package/src/state/dedup-ledger.ts +39 -0
- package/src/state/interaction-buffer.ts +73 -0
- package/src/state/mmkv-store.ts +71 -0
- package/src/state/permission-history.ts +41 -0
- package/src/state/record.ts +246 -0
- package/src/state/reset.ts +41 -0
- package/src/state/series-ledger.ts +84 -0
- package/src/state/store.ts +40 -0
- package/src/testing/conformance/index.ts +194 -0
- package/src/testing/fake-engine.ts +284 -0
- package/src/testing/fake-provider.ts +129 -0
- package/src/testing/faults.ts +72 -0
- package/src/testing/index.ts +26 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory engine for tests.
|
|
3
|
+
*
|
|
4
|
+
* Exists so every decision path is provable with no device, no emulator, and
|
|
5
|
+
* no live provider. Its controls simulate the conditions that are otherwise
|
|
6
|
+
* only reachable on hardware: virtual time, time-zone travel, permission
|
|
7
|
+
* transitions, process death, boot-restoration gaps, and platform truncation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
NotificationEngineAdapter,
|
|
12
|
+
EngineCapabilities,
|
|
13
|
+
EngineScheduleRequest,
|
|
14
|
+
} from '../engine/adapter';
|
|
15
|
+
import type { PendingEntry } from '../contract/notification';
|
|
16
|
+
import type { PermissionKind, PermissionState, RestrictionReport } from '../contract/permission';
|
|
17
|
+
import type { ResolvedTone, ChannelGroup } from '../contract/channel';
|
|
18
|
+
import type { InteractionEvent, Unsubscribe } from '../contract/interaction';
|
|
19
|
+
|
|
20
|
+
export interface FakeEngineOptions {
|
|
21
|
+
readonly pendingCeiling?: number;
|
|
22
|
+
readonly supportsExactAlarms?: boolean;
|
|
23
|
+
readonly supportsDoNotDisturbBypass?: boolean;
|
|
24
|
+
readonly backgroundWindowMs?: number;
|
|
25
|
+
readonly initialPermissions?: Partial<Record<PermissionKind, PermissionState>>;
|
|
26
|
+
readonly timeZone?: string;
|
|
27
|
+
readonly now?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Every platform operation the engine was asked to perform, in order. */
|
|
31
|
+
export interface RecordedOperation {
|
|
32
|
+
readonly kind: 'schedule' | 'cancel' | 'cancelAll' | 'declareTone' | 'retireTone';
|
|
33
|
+
readonly id?: string;
|
|
34
|
+
readonly at: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface FakeControls {
|
|
38
|
+
/** Operations recorded since the last reset. The idempotence assertion reads this. */
|
|
39
|
+
readonly operations: readonly RecordedOperation[];
|
|
40
|
+
clearOperations(): void;
|
|
41
|
+
|
|
42
|
+
/** Virtual clock, so tests never sleep. */
|
|
43
|
+
now(): number;
|
|
44
|
+
advanceTime(ms: number): void;
|
|
45
|
+
setTime(epochMs: number): void;
|
|
46
|
+
|
|
47
|
+
/** Simulates the user travelling. */
|
|
48
|
+
timeZone(): string;
|
|
49
|
+
setTimeZone(zone: string): void;
|
|
50
|
+
|
|
51
|
+
setPermission(kind: PermissionKind, state: PermissionState): void;
|
|
52
|
+
setRestrictions(report: Partial<RestrictionReport>): void;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Drops entries the platform would have silently discarded past its ceiling,
|
|
56
|
+
* so truncation behavior is testable without an iOS device.
|
|
57
|
+
*/
|
|
58
|
+
applyPlatformCeiling(): readonly string[];
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Simulates the process dying: in-memory listeners are lost while scheduled
|
|
62
|
+
* notifications survive, which is exactly what happens on a real device.
|
|
63
|
+
*/
|
|
64
|
+
simulateProcessDeath(): void;
|
|
65
|
+
|
|
66
|
+
/** Simulates a manufacturer losing notifications across a reboot. */
|
|
67
|
+
simulateBootGap(ids: readonly string[]): void;
|
|
68
|
+
|
|
69
|
+
/** Queues an interaction that arrived before any handler existed. */
|
|
70
|
+
emitInteraction(event: InteractionEvent): void;
|
|
71
|
+
queueInitialInteraction(event: InteractionEvent): void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type FakeEngine = NotificationEngineAdapter & FakeControls;
|
|
75
|
+
|
|
76
|
+
export function createFakeEngine(options: FakeEngineOptions = {}): FakeEngine {
|
|
77
|
+
const scheduled = new Map<string, PendingEntry>();
|
|
78
|
+
const channels = new Set<string>();
|
|
79
|
+
let operations: RecordedOperation[] = [];
|
|
80
|
+
let listeners: ((event: InteractionEvent) => void)[] = [];
|
|
81
|
+
let initialInteraction: InteractionEvent | undefined;
|
|
82
|
+
|
|
83
|
+
let currentTime = options.now ?? Date.UTC(2026, 0, 1, 12, 0, 0);
|
|
84
|
+
let currentZone = options.timeZone ?? 'UTC';
|
|
85
|
+
|
|
86
|
+
const permissions: Record<PermissionKind, PermissionState> = {
|
|
87
|
+
display: options.initialPermissions?.display ?? 'notAsked',
|
|
88
|
+
exactAlarm: options.initialPermissions?.exactAlarm ?? 'notAsked',
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
let restrictions: RestrictionReport = {
|
|
92
|
+
batteryOptimization: 'unrestricted',
|
|
93
|
+
autoStart: 'unrestricted',
|
|
94
|
+
readAt: currentTime,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const capabilities: EngineCapabilities = {
|
|
98
|
+
pendingCeiling: options.pendingCeiling ?? 64,
|
|
99
|
+
supportsExactAlarms: options.supportsExactAlarms ?? true,
|
|
100
|
+
supportsChannels: true,
|
|
101
|
+
supportsTextInputActions: true,
|
|
102
|
+
supportsDoNotDisturbBypass: options.supportsDoNotDisturbBypass ?? true,
|
|
103
|
+
backgroundWindowMs: options.backgroundWindowMs ?? 30_000,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const record = (kind: RecordedOperation['kind'], id?: string): void => {
|
|
107
|
+
operations.push({ kind, id, at: currentTime });
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const toPendingEntry = (request: EngineScheduleRequest): PendingEntry => ({
|
|
111
|
+
id: request.request.id,
|
|
112
|
+
resolvedAt: request.resolvedAt,
|
|
113
|
+
anchoring: request.request.moment.anchoring ?? 'wallClock',
|
|
114
|
+
anchoringGeneration:
|
|
115
|
+
(request.request.moment.anchoring ?? 'wallClock') === 'wallClock' ? currentZone : undefined,
|
|
116
|
+
content: request.request.content,
|
|
117
|
+
tone: request.request.tone,
|
|
118
|
+
routing: request.request.routing,
|
|
119
|
+
actions: request.request.actions,
|
|
120
|
+
ownerTag: request.request.ownerTag,
|
|
121
|
+
groupTag: request.request.groupTag,
|
|
122
|
+
seriesId: request.seriesId,
|
|
123
|
+
mayBeDelayed: request.approximate,
|
|
124
|
+
pushCreated: request.pushCreated ?? false,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
name: 'fake',
|
|
129
|
+
|
|
130
|
+
async getCapabilities() {
|
|
131
|
+
return capabilities;
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
async schedule(request) {
|
|
135
|
+
record('schedule', request.request.id);
|
|
136
|
+
scheduled.set(request.request.id, toPendingEntry(request));
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
async cancel(id) {
|
|
140
|
+
record('cancel', id);
|
|
141
|
+
scheduled.delete(id);
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
async cancelMany(ids) {
|
|
145
|
+
for (const id of ids) {
|
|
146
|
+
record('cancel', id);
|
|
147
|
+
scheduled.delete(id);
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
async cancelAll() {
|
|
152
|
+
record('cancelAll');
|
|
153
|
+
scheduled.clear();
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
async getPending() {
|
|
157
|
+
return [...scheduled.values()].sort((a, b) => a.resolvedAt - b.resolvedAt);
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
async declareTone(tone: ResolvedTone) {
|
|
161
|
+
record('declareTone', tone.android.channelId);
|
|
162
|
+
if (channels.has(tone.android.channelId)) {
|
|
163
|
+
// Android silently ignores re-declaration. The fake does too, on purpose.
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
channels.add(tone.android.channelId);
|
|
167
|
+
return true;
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
async retireTone(channelId: string) {
|
|
171
|
+
record('retireTone', channelId);
|
|
172
|
+
return channels.delete(channelId);
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
async declareGroups(_groups: readonly ChannelGroup[]) {
|
|
176
|
+
// Groups are presentational; the fake accepts them without recording.
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
async listDeclaredChannels() {
|
|
180
|
+
return [...channels];
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
async getPermission(kind) {
|
|
184
|
+
return permissions[kind];
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
async requestPermission(kind) {
|
|
188
|
+
if (permissions[kind] === 'notAsked') {
|
|
189
|
+
permissions[kind] = 'granted';
|
|
190
|
+
}
|
|
191
|
+
return permissions[kind];
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
async openSettings(_kind) {
|
|
195
|
+
// No-op: the fake cannot open a settings screen, and pretending it did would be a lie.
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
async getRestrictions() {
|
|
199
|
+
return { ...restrictions, readAt: currentTime };
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
subscribeToInteractions(handler): Unsubscribe {
|
|
203
|
+
listeners.push(handler);
|
|
204
|
+
return () => {
|
|
205
|
+
listeners = listeners.filter(l => l !== handler);
|
|
206
|
+
};
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
async takeInitialInteraction() {
|
|
210
|
+
const event = initialInteraction;
|
|
211
|
+
initialInteraction = undefined;
|
|
212
|
+
return event;
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
// --- FakeControls ------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
get operations() {
|
|
218
|
+
return operations;
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
clearOperations() {
|
|
222
|
+
operations = [];
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
now() {
|
|
226
|
+
return currentTime;
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
advanceTime(ms) {
|
|
230
|
+
currentTime += ms;
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
setTime(epochMs) {
|
|
234
|
+
currentTime = epochMs;
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
timeZone() {
|
|
238
|
+
return currentZone;
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
setTimeZone(zone) {
|
|
242
|
+
currentZone = zone;
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
setPermission(kind, state) {
|
|
246
|
+
permissions[kind] = state;
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
setRestrictions(report) {
|
|
250
|
+
restrictions = { ...restrictions, ...report };
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
applyPlatformCeiling() {
|
|
254
|
+
const ordered = [...scheduled.values()].sort((a, b) => a.resolvedAt - b.resolvedAt);
|
|
255
|
+
const dropped = ordered.slice(capabilities.pendingCeiling);
|
|
256
|
+
for (const entry of dropped) {
|
|
257
|
+
scheduled.delete(entry.id);
|
|
258
|
+
}
|
|
259
|
+
return dropped.map(entry => entry.id);
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
simulateProcessDeath() {
|
|
263
|
+
// Scheduled notifications live in the OS and survive; listeners do not.
|
|
264
|
+
listeners = [];
|
|
265
|
+
operations = [];
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
simulateBootGap(ids) {
|
|
269
|
+
for (const id of ids) {
|
|
270
|
+
scheduled.delete(id);
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
emitInteraction(event) {
|
|
275
|
+
for (const listener of listeners) {
|
|
276
|
+
listener(event);
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
queueInitialInteraction(event) {
|
|
281
|
+
initialInteraction = event;
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory push provider for tests.
|
|
3
|
+
*
|
|
4
|
+
* Exists so the entire push surface — token lifecycle, receipt in every app
|
|
5
|
+
* state, malformed payloads, redelivery, and provider unavailability — is
|
|
6
|
+
* provable with no live provider and no device.
|
|
7
|
+
*
|
|
8
|
+
* It is also the second adapter that proves the swap costs a consumer nothing:
|
|
9
|
+
* running the full push suite against this instead of the real provider must
|
|
10
|
+
* pass with zero changes to consuming code.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
PushAvailability,
|
|
15
|
+
PushProviderAdapter,
|
|
16
|
+
} from '../push/provider-adapter';
|
|
17
|
+
import type { Unsubscribe } from '../contract/interaction';
|
|
18
|
+
|
|
19
|
+
export interface FakeProviderOptions {
|
|
20
|
+
readonly name?: string;
|
|
21
|
+
readonly availability?: PushAvailability;
|
|
22
|
+
readonly initialToken?: string;
|
|
23
|
+
/** When true, `getToken` resolves only after `releaseToken()` is called. */
|
|
24
|
+
readonly withholdToken?: boolean;
|
|
25
|
+
readonly invalidationSucceeds?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface FakeProviderControls {
|
|
29
|
+
/** Delivers a raw payload as the provider would. */
|
|
30
|
+
deliver(raw: unknown): void;
|
|
31
|
+
/** Queues the payload that launched the app. */
|
|
32
|
+
queueInitial(raw: unknown): void;
|
|
33
|
+
/** Simulates the platform rotating the registration token. */
|
|
34
|
+
rotateToken(token: string): void;
|
|
35
|
+
/** Resolves a withheld token, so "waits for availability" is testable. */
|
|
36
|
+
releaseToken(token: string): void;
|
|
37
|
+
setAvailability(availability: PushAvailability): void;
|
|
38
|
+
readonly invalidated: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type FakeProvider = PushProviderAdapter & FakeProviderControls;
|
|
42
|
+
|
|
43
|
+
export function createFakeProvider(options: FakeProviderOptions = {}): FakeProvider {
|
|
44
|
+
let availability: PushAvailability = options.availability ?? 'available';
|
|
45
|
+
let token = options.withholdToken === true ? undefined : (options.initialToken ?? 'fake-token-1');
|
|
46
|
+
let invalidated = false;
|
|
47
|
+
let messageHandlers: ((raw: unknown) => void)[] = [];
|
|
48
|
+
let tokenHandlers: ((token: string) => void)[] = [];
|
|
49
|
+
let initial: unknown | undefined;
|
|
50
|
+
let waiters: ((token: string) => void)[] = [];
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
name: options.name ?? 'fake-provider',
|
|
54
|
+
|
|
55
|
+
async getAvailability() {
|
|
56
|
+
return availability;
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
async getToken() {
|
|
60
|
+
if (token !== undefined) return token;
|
|
61
|
+
// Waits rather than returning a placeholder — an empty string presented
|
|
62
|
+
// as a token is worse than not answering yet.
|
|
63
|
+
return new Promise<string>(resolve => {
|
|
64
|
+
waiters.push(resolve);
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
onTokenChange(handler): Unsubscribe {
|
|
69
|
+
tokenHandlers.push(handler);
|
|
70
|
+
return () => {
|
|
71
|
+
tokenHandlers = tokenHandlers.filter(h => h !== handler);
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
async invalidateToken() {
|
|
76
|
+
invalidated = true;
|
|
77
|
+
token = undefined;
|
|
78
|
+
return options.invalidationSucceeds ?? true;
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
subscribeToMessages(handler): Unsubscribe {
|
|
82
|
+
messageHandlers.push(handler);
|
|
83
|
+
return () => {
|
|
84
|
+
messageHandlers = messageHandlers.filter(h => h !== handler);
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
async takeInitialMessage() {
|
|
89
|
+
const raw = initial;
|
|
90
|
+
initial = undefined;
|
|
91
|
+
return raw;
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
// --- FakeProviderControls ---------------------------------------------
|
|
95
|
+
|
|
96
|
+
deliver(raw) {
|
|
97
|
+
for (const handler of messageHandlers) {
|
|
98
|
+
handler(raw);
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
queueInitial(raw) {
|
|
103
|
+
initial = raw;
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
rotateToken(next) {
|
|
107
|
+
token = next;
|
|
108
|
+
for (const handler of tokenHandlers) {
|
|
109
|
+
handler(next);
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
releaseToken(next) {
|
|
114
|
+
token = next;
|
|
115
|
+
for (const resolve of waiters) {
|
|
116
|
+
resolve(next);
|
|
117
|
+
}
|
|
118
|
+
waiters = [];
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
setAvailability(next) {
|
|
122
|
+
availability = next;
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
get invalidated() {
|
|
126
|
+
return invalidated;
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fault injection.
|
|
3
|
+
*
|
|
4
|
+
* Every documented failure condition must be reachable on demand, or the claim
|
|
5
|
+
* that each has its own distinct diagnostic is untestable. This is what the
|
|
6
|
+
* demo app's fault panel and the conformance suite both drive.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { ALL_DIAGNOSTIC_CODES, type DiagnosticCode } from '../contract/errors';
|
|
10
|
+
|
|
11
|
+
export type FaultKind = DiagnosticCode;
|
|
12
|
+
|
|
13
|
+
export interface FaultRecord {
|
|
14
|
+
readonly code: DiagnosticCode;
|
|
15
|
+
readonly at: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Disposable {
|
|
19
|
+
dispose(): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface FaultInjectorOptions {
|
|
23
|
+
readonly now?: () => number;
|
|
24
|
+
readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Triggers a named condition and records that it fired.
|
|
29
|
+
*
|
|
30
|
+
* Deliberately a thin mechanism: it proves each identifier is reachable and
|
|
31
|
+
* distinct, which is the property under test. Making the *real* condition occur
|
|
32
|
+
* is each module's own test's job.
|
|
33
|
+
*/
|
|
34
|
+
export function createFaultInjector(options: FaultInjectorOptions = {}) {
|
|
35
|
+
const now = options.now ?? (() => Date.now());
|
|
36
|
+
const emit = options.emit ?? (() => {});
|
|
37
|
+
const fired: FaultRecord[] = [];
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
/** Every condition a consumer can reach from the fault panel. */
|
|
41
|
+
available: ALL_DIAGNOSTIC_CODES,
|
|
42
|
+
|
|
43
|
+
inject(kind: FaultKind): Disposable {
|
|
44
|
+
const record: FaultRecord = { code: kind, at: now() };
|
|
45
|
+
fired.push(record);
|
|
46
|
+
emit(kind, `Injected fault: ${kind}`, { injected: true });
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
dispose() {
|
|
50
|
+
const index = fired.indexOf(record);
|
|
51
|
+
if (index >= 0) fired.splice(index, 1);
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
get fired(): readonly FaultRecord[] {
|
|
57
|
+
return fired;
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
/** Which documented conditions have not yet been exercised. */
|
|
61
|
+
unexercised(): readonly DiagnosticCode[] {
|
|
62
|
+
const seen = new Set(fired.map(record => record.code));
|
|
63
|
+
return ALL_DIAGNOSTIC_CODES.filter(code => !seen.has(code));
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
reset(): void {
|
|
67
|
+
fired.length = 0;
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type FaultInjector = ReturnType<typeof createFaultInjector>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test-only surface: `@chipmobilesdk/rn-notification/testing`
|
|
3
|
+
*
|
|
4
|
+
* Kept behind its own subpath so fakes and fault injection never reach a
|
|
5
|
+
* production bundle. Everything here exists so that scheduling, reconciliation,
|
|
6
|
+
* permission transitions, tone mapping, and interaction routing are provable
|
|
7
|
+
* with no device, no emulator, and no live provider.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { createFakeEngine } from './fake-engine';
|
|
11
|
+
export { createFakeProvider } from './fake-provider';
|
|
12
|
+
export { createFaultInjector } from './faults';
|
|
13
|
+
export { runConformanceSuite } from './conformance';
|
|
14
|
+
export type { ConformanceReport, ConformanceCheck, ConformanceOptions } from './conformance';
|
|
15
|
+
export type { FaultInjector, FaultKind, FaultRecord, Disposable } from './faults';
|
|
16
|
+
export type { FakeProvider, FakeProviderOptions, FakeProviderControls } from './fake-provider';
|
|
17
|
+
export type {
|
|
18
|
+
FakeEngine,
|
|
19
|
+
FakeEngineOptions,
|
|
20
|
+
FakeControls,
|
|
21
|
+
RecordedOperation,
|
|
22
|
+
} from './fake-engine';
|
|
23
|
+
|
|
24
|
+
/** In-memory state store, for testing state behavior without a native module. */
|
|
25
|
+
export { createMemoryStore } from '../state/store';
|
|
26
|
+
export type { StateStore } from '../state/store';
|