@chipmobilesdk/rn-ads 0.1.0 → 0.2.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.
@@ -17,13 +17,89 @@
17
17
  */
18
18
 
19
19
  import type { FullScreenFormat, Reward, Unsubscribe } from '../config/types';
20
+ import type { Scheduler } from '../core/timers';
20
21
  import type {
21
22
  AdsEngine,
23
+ ConsentPort,
22
24
  EngineAdEvent,
23
25
  EngineAdHandle,
26
+ EngineConsentInfo,
27
+ EngineConsentOptions,
24
28
  EnginePrivacy,
25
29
  } from '../engine/port';
26
30
 
31
+ /**
32
+ * A clock a test can move by hand.
33
+ *
34
+ * Retry backoff, app-open expiry, the undecided-state warning, and the
35
+ * ad-originated-return windows are all time-dependent, and all four must be
36
+ * assertable exactly rather than approximately. Advancing a clock makes "two
37
+ * retries, 2s then 4s" a precise assertion; sleeping would make it a flaky one.
38
+ *
39
+ * Callbacks fire in due order, and a callback scheduled *during* an advance is
40
+ * honored within the same advance if its deadline has also passed — matching how
41
+ * real timers behave when work chains.
42
+ */
43
+ export interface TestClock {
44
+ now(): number;
45
+ advance(ms: number): void;
46
+ readonly scheduler: Scheduler;
47
+ pending(): number;
48
+ }
49
+
50
+ export function createTestClock(startMs = 1_000_000): TestClock {
51
+ let current = startMs;
52
+ let sequence = 0;
53
+ const timers = new Map<number, { at: number; callback: () => void }>();
54
+
55
+ const scheduler: Scheduler = {
56
+ setTimeout(callback, ms) {
57
+ sequence += 1;
58
+ timers.set(sequence, { at: current + ms, callback });
59
+ return sequence;
60
+ },
61
+ clearTimeout(handle) {
62
+ timers.delete(handle as number);
63
+ },
64
+ };
65
+
66
+ return {
67
+ now: () => current,
68
+ advance(ms) {
69
+ const target = current + ms;
70
+ // Loop rather than iterate once: a fired callback may schedule another
71
+ // timer that is also due before `target`, which is exactly what a retry
72
+ // chain does.
73
+ for (;;) {
74
+ let nextId: number | null = null;
75
+ let nextAt = Number.POSITIVE_INFINITY;
76
+ for (const [id, timer] of timers) {
77
+ if (timer.at <= target && timer.at < nextAt) {
78
+ nextAt = timer.at;
79
+ nextId = id;
80
+ }
81
+ }
82
+ if (nextId === null) {
83
+ break;
84
+ }
85
+ const timer = timers.get(nextId)!;
86
+ timers.delete(nextId);
87
+ current = timer.at;
88
+ timer.callback();
89
+ }
90
+ current = target;
91
+ },
92
+ scheduler,
93
+ pending: () => timers.size,
94
+ };
95
+ }
96
+
97
+ export type FakeConsentCall =
98
+ | { readonly kind: 'requestInfoUpdate'; readonly options: EngineConsentOptions }
99
+ | { readonly kind: 'showFormIfRequired' }
100
+ | { readonly kind: 'getInfo' }
101
+ | { readonly kind: 'reset' };
102
+
27
103
  export type FakeEngineCall =
28
104
  | { readonly kind: 'initialize'; readonly privacy: EnginePrivacy }
29
105
  | {
@@ -47,8 +123,18 @@ export interface FakeEngineOptions {
47
123
  readonly neverCallBack?: boolean;
48
124
  /** Rejects `show`, for the display-failure path (FR-044). */
49
125
  readonly failShow?: unknown;
126
+ /** Initial consent information. Defaults to the not-required, allowed case. */
127
+ readonly consentInfo?: Partial<EngineConsentInfo>;
128
+ /** Drives time for retry, expiry, and the undecided warning. */
129
+ readonly clock?: TestClock;
50
130
  }
51
131
 
132
+ const DEFAULT_CONSENT: EngineConsentInfo = {
133
+ status: 'not_required',
134
+ canRequestAds: true,
135
+ isFormAvailable: false,
136
+ };
137
+
52
138
  export interface FakeAdsEngine extends AdsEngine {
53
139
  /** Every call the package made, in order. */
54
140
  readonly calls: readonly FakeEngineCall[];
@@ -66,10 +152,60 @@ export interface FakeAdsEngine extends AdsEngine {
66
152
  listenerCount(): number;
67
153
  /** Drops every recorded call, for tests that assert on a later window only. */
68
154
  reset(): void;
155
+
156
+ /** The clock driving this engine's timers. Wire it into `createAds`. */
157
+ readonly clock: TestClock;
158
+ /** Moves the clock and fires anything due. */
159
+ advance(ms: number): void;
160
+
161
+ /** Every consent call the package made, in order (FR-113's assertion surface). */
162
+ readonly consentCalls: readonly FakeConsentCall[];
163
+ /** Sets what the consent platform will report from now on. */
164
+ setConsentInfo(info: Partial<EngineConsentInfo>): void;
165
+ /** Makes `requestInfoUpdate` reject — the flow must yield `error`, never granted. */
166
+ failConsentRequest(cause?: unknown): void;
167
+ /** Makes `showFormIfRequired` reject — dismissal is not consent (FR-106). */
168
+ failConsentForm(cause?: unknown): void;
69
169
  }
70
170
 
71
171
  export function createFakeAdsEngine(options: FakeEngineOptions = {}): FakeAdsEngine {
72
172
  const calls: FakeEngineCall[] = [];
173
+ const consentCalls: FakeConsentCall[] = [];
174
+ const clock = options.clock ?? createTestClock();
175
+ let consentInfo: EngineConsentInfo = { ...DEFAULT_CONSENT, ...options.consentInfo };
176
+ let consentRequestFailure: unknown;
177
+ let consentFormFailure: unknown;
178
+
179
+ const consent: ConsentPort = {
180
+ async requestInfoUpdate(consentOptions) {
181
+ consentCalls.push({ kind: 'requestInfoUpdate', options: consentOptions });
182
+ if (consentRequestFailure !== undefined) {
183
+ throw consentRequestFailure;
184
+ }
185
+ return consentInfo;
186
+ },
187
+ async showFormIfRequired() {
188
+ consentCalls.push({ kind: 'showFormIfRequired' });
189
+ if (consentFormFailure !== undefined) {
190
+ throw consentFormFailure;
191
+ }
192
+ // A presented form that the user completes moves the status on. The
193
+ // fake only does this when a form was actually required, mirroring the
194
+ // provider: showing nothing cannot change an answer.
195
+ if (consentInfo.status === 'required' && consentInfo.isFormAvailable) {
196
+ consentInfo = { status: 'obtained', canRequestAds: true, isFormAvailable: false };
197
+ }
198
+ return consentInfo;
199
+ },
200
+ async getInfo() {
201
+ consentCalls.push({ kind: 'getInfo' });
202
+ return consentInfo;
203
+ },
204
+ reset() {
205
+ consentCalls.push({ kind: 'reset' });
206
+ consentInfo = { status: 'unknown', canRequestAds: false, isFormAvailable: true };
207
+ },
208
+ };
73
209
  const listeners = new Map<string, Set<(event: EngineAdEvent) => void>>();
74
210
  /** Callbacks queued before anyone subscribed; see `subscribe`. */
75
211
  const pending = new Map<string, EngineAdEvent>();
@@ -82,6 +218,25 @@ export function createFakeAdsEngine(options: FakeEngineOptions = {}): FakeAdsEng
82
218
 
83
219
  return {
84
220
  calls,
221
+ consentCalls,
222
+ consent,
223
+ clock,
224
+
225
+ advance(ms) {
226
+ clock.advance(ms);
227
+ },
228
+
229
+ setConsentInfo(info) {
230
+ consentInfo = { ...consentInfo, ...info };
231
+ },
232
+
233
+ failConsentRequest(cause = new Error('consent request failed')) {
234
+ consentRequestFailure = cause;
235
+ },
236
+
237
+ failConsentForm(cause = new Error('consent form failed')) {
238
+ consentFormFailure = cause;
239
+ },
85
240
 
86
241
  isAvailable() {
87
242
  return options.available !== false;