@chipmobilesdk/rn-ads 0.6.0 → 0.7.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 +51 -0
- package/README.md +4 -2
- package/package.json +1 -1
- package/skills/sdk-rn-ads/SKILL.md +2 -1
- package/src/core/adsInstance.ts +72 -1829
- package/src/core/consentFlow.ts +1 -20
- package/src/core/instance/consent.ts +162 -0
- package/src/core/instance/context.ts +328 -0
- package/src/core/instance/gate.ts +147 -0
- package/src/core/instance/init.ts +262 -0
- package/src/core/instance/internals.ts +138 -0
- package/src/core/instance/load.ts +426 -0
- package/src/core/instance/show.ts +202 -0
- package/src/core/instance/types.ts +198 -0
- package/src/core/requestThrottle.ts +11 -17
- package/src/diagnostics/errors.ts +24 -5
- package/src/engine/admobBanner.tsx +55 -0
- package/src/engine/admobEngine.ts +21 -12
- package/src/engine/admobErrors.ts +44 -0
- package/src/engine/port.ts +46 -1
- package/src/engine/testAdUnits.ts +0 -5
- package/src/react/AdsBanner.tsx +81 -80
- package/src/testing/clock.ts +75 -0
- package/src/testing/index.ts +29 -68
package/src/react/AdsBanner.tsx
CHANGED
|
@@ -16,14 +16,15 @@
|
|
|
16
16
|
* a banner through the load/show handle model, which does not fit it.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
19
|
+
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
20
20
|
import { View, type LayoutChangeEvent, type ViewStyle } from 'react-native';
|
|
21
21
|
|
|
22
22
|
import type { PlacementState } from '../config/types';
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
23
|
+
import { normalizeProviderError } from '../diagnostics/errors';
|
|
24
|
+
import type { AdEvent } from '../diagnostics/events';
|
|
25
|
+
import type { EngineFailureKind } from '../engine/port';
|
|
25
26
|
import { useAds } from './AdsProvider';
|
|
26
|
-
import type
|
|
27
|
+
import { getInternals, type ThrottledResult } from '../core/adsInstance';
|
|
27
28
|
import { useAdsStatus } from './hooks';
|
|
28
29
|
|
|
29
30
|
export interface AdsBannerProps {
|
|
@@ -34,20 +35,14 @@ export interface AdsBannerProps {
|
|
|
34
35
|
readonly style?: ViewStyle;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
interface ProviderBannerModule {
|
|
38
|
-
BannerAd: React.ComponentType<Record<string, unknown>>;
|
|
39
|
-
BannerAdSize: Record<string, string>;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// The engine's lazy loader, so the bridge is required in one place (ADS-12 still
|
|
43
|
-
// tracks giving the banner its own seam on the engine port).
|
|
44
|
-
function loadBannerModule(): ProviderBannerModule | null {
|
|
45
|
-
const result = admobProvider.get();
|
|
46
|
-
return result.ok ? (result.module as unknown as ProviderBannerModule) : null;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
38
|
export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps) {
|
|
50
39
|
const ads = useAds();
|
|
40
|
+
// The instance's banner wiring (ADS-13). An instance the app wrote by hand has
|
|
41
|
+
// none, and is treated like a missing one.
|
|
42
|
+
const internals = useMemo(() => (ads ? getInternals(ads) : null), [ads]);
|
|
43
|
+
// The throttle token for this mount: stable across renders and minted by React
|
|
44
|
+
// rather than by a counter, so a render that runs twice charges one slot.
|
|
45
|
+
const mountToken = useId();
|
|
51
46
|
// The reported state lives in a ref rather than in component state: the host
|
|
52
47
|
// itself renders the same tree for every state, so storing it would only
|
|
53
48
|
// force re-renders the PERF-002 budget does not need to spend. The instance
|
|
@@ -69,23 +64,29 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
|
|
|
69
64
|
decided: boolean;
|
|
70
65
|
refusal: ThrottledResult | null;
|
|
71
66
|
}>({ decided: false, refusal: null });
|
|
67
|
+
// Which attempt this mount is on. A refused mount asks again once the window
|
|
68
|
+
// passes (ADS-06), and each attempt needs its own token: the throttle replays
|
|
69
|
+
// the stored verdict for a token it has seen.
|
|
70
|
+
const throttleAttemptRef = useRef(0);
|
|
72
71
|
|
|
73
|
-
|
|
72
|
+
// The engine's banner component (ADS-12): the host never names the provider.
|
|
73
|
+
const banner = useMemo(() => internals?.banner() ?? null, [internals]);
|
|
74
74
|
// Initialization is asynchronous and a banner is almost always mounted before
|
|
75
75
|
// it finishes. Without this the resolution below would be computed once, while
|
|
76
76
|
// the instance was still `not_started`, be refused, and never be retried — the
|
|
77
77
|
// banner would sit at `unavailable` forever on a perfectly healthy app.
|
|
78
78
|
const status = useAdsStatus();
|
|
79
79
|
|
|
80
|
-
// Keyed on `cycle` so a genuine width change
|
|
81
|
-
//
|
|
82
|
-
//
|
|
80
|
+
// Keyed on `cycle` so a genuine width change starts a new, separately traceable
|
|
81
|
+
// load (FR-038, FR-052), and on `status` so the banner recovers the moment the
|
|
82
|
+
// instance becomes able to request. Pure: the cycle's correlation id is minted
|
|
83
|
+
// in the effect that starts it, not here (ADS-20).
|
|
83
84
|
const resolved = useMemo(
|
|
84
85
|
() =>
|
|
85
|
-
|
|
86
|
-
? { cycle, value:
|
|
86
|
+
internals && status === 'ready'
|
|
87
|
+
? { cycle, value: internals.resolveBanner(placementKey) }
|
|
87
88
|
: null,
|
|
88
|
-
[
|
|
89
|
+
[internals, placementKey, cycle, status],
|
|
89
90
|
);
|
|
90
91
|
const resolution = resolved?.value ?? null;
|
|
91
92
|
|
|
@@ -95,10 +96,10 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
|
|
|
95
96
|
return;
|
|
96
97
|
}
|
|
97
98
|
stateRef.current = next;
|
|
98
|
-
|
|
99
|
+
internals?.setPlacementState(placementKey, next);
|
|
99
100
|
onStateChange?.(next);
|
|
100
101
|
},
|
|
101
|
-
[
|
|
102
|
+
[internals, onStateChange, placementKey],
|
|
102
103
|
);
|
|
103
104
|
|
|
104
105
|
// FR-140. Decided **during render**, not in the effect below, because the
|
|
@@ -111,20 +112,23 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
|
|
|
111
112
|
// out), while a later cycle in the *same* mount is a width re-adaptation —
|
|
112
113
|
// rotation — which must not be refused or a rotated screen loses its banner
|
|
113
114
|
// for up to a minute.
|
|
114
|
-
if (
|
|
115
|
+
if (internals && resolution?.ok && containerWidth > 0 && !throttleRef.current.decided) {
|
|
115
116
|
throttleRef.current = {
|
|
116
117
|
decided: true,
|
|
117
|
-
refusal:
|
|
118
|
+
refusal: internals.commitBannerRequest(
|
|
119
|
+
placementKey,
|
|
120
|
+
`${mountToken}:${throttleAttemptRef.current}`,
|
|
121
|
+
),
|
|
118
122
|
};
|
|
119
123
|
}
|
|
120
124
|
const throttleRefusal = throttleRef.current.refusal;
|
|
121
125
|
|
|
122
126
|
const usable = Boolean(
|
|
123
|
-
|
|
127
|
+
internals && banner && resolution?.ok && containerWidth > 0 && !throttleRefusal,
|
|
124
128
|
);
|
|
125
129
|
|
|
126
130
|
useEffect(() => {
|
|
127
|
-
if (!
|
|
131
|
+
if (!internals || !banner) {
|
|
128
132
|
move('unavailable');
|
|
129
133
|
return;
|
|
130
134
|
}
|
|
@@ -168,31 +172,39 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
|
|
|
168
172
|
// collapse the space for both, but only one is worth investigating.
|
|
169
173
|
if (throttleRefusal) {
|
|
170
174
|
move('throttled');
|
|
171
|
-
|
|
175
|
+
internals.emit({
|
|
172
176
|
type: 'request_throttled',
|
|
173
|
-
timestamp:
|
|
177
|
+
timestamp: internals.now(),
|
|
174
178
|
placementKey,
|
|
175
179
|
format: 'banner',
|
|
176
180
|
retryAfterMs: throttleRefusal.retryAfterMs,
|
|
177
|
-
minRequestIntervalMs:
|
|
181
|
+
minRequestIntervalMs: internals.throttleFloorFor(placementKey),
|
|
182
|
+
});
|
|
183
|
+
// ADS-06: ask again in this same mount once the window has passed. Until
|
|
184
|
+
// 0.7.0 a refused banner stayed empty until it was remounted, so a banner
|
|
185
|
+
// pinned to a screen the user returned to within a minute never filled.
|
|
186
|
+
return internals.schedule(throttleRefusal.retryAfterMs, () => {
|
|
187
|
+
throttleRef.current = { decided: false, refusal: null };
|
|
188
|
+
throttleAttemptRef.current += 1;
|
|
189
|
+
setCycle(value => value + 1);
|
|
178
190
|
});
|
|
179
|
-
return;
|
|
180
191
|
}
|
|
181
|
-
|
|
192
|
+
const correlationId = internals.nextCorrelationId();
|
|
193
|
+
correlationRef.current = correlationId;
|
|
182
194
|
loadedInCycleRef.current = false;
|
|
183
|
-
startedAtRef.current =
|
|
195
|
+
startedAtRef.current = internals.now();
|
|
184
196
|
move('loading');
|
|
185
|
-
|
|
197
|
+
internals.emit({
|
|
186
198
|
type: 'load_started',
|
|
187
|
-
timestamp:
|
|
199
|
+
timestamp: startedAtRef.current,
|
|
188
200
|
placementKey,
|
|
189
201
|
format: 'banner',
|
|
190
|
-
correlationId
|
|
202
|
+
correlationId,
|
|
191
203
|
});
|
|
192
204
|
// Deliberately keyed on `cycle`, not on every render: FR-041 forbids a
|
|
193
205
|
// re-render from producing a second uncontrolled request.
|
|
194
206
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
195
|
-
}, [
|
|
207
|
+
}, [internals, banner, status, resolution, containerWidth, cycle]);
|
|
196
208
|
|
|
197
209
|
const onLayout = useCallback((event: LayoutChangeEvent) => {
|
|
198
210
|
const width = Math.round(event.nativeEvent.layout.width);
|
|
@@ -210,22 +222,23 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
|
|
|
210
222
|
}, []);
|
|
211
223
|
|
|
212
224
|
const emit = useCallback(
|
|
213
|
-
(event:
|
|
214
|
-
|
|
225
|
+
(event: AdEvent) => {
|
|
226
|
+
internals?.emit(event);
|
|
215
227
|
},
|
|
216
|
-
[
|
|
228
|
+
[internals],
|
|
217
229
|
);
|
|
230
|
+
const clock = useCallback(() => internals?.now() ?? Date.now(), [internals]);
|
|
218
231
|
|
|
219
232
|
const handleLoaded = useCallback(() => {
|
|
220
233
|
// A second load in one correlation is the provider's auto-refresh: a new
|
|
221
234
|
// ad, so a new cycle. Under the old correlation its `loaded` and
|
|
222
235
|
// `impression` were deduplicated away and revenue went uncounted (ADS-05).
|
|
223
236
|
// For a refresh, `durationMs` is the time since the previous load.
|
|
224
|
-
if (loadedInCycleRef.current &&
|
|
225
|
-
correlationRef.current =
|
|
237
|
+
if (loadedInCycleRef.current && internals) {
|
|
238
|
+
correlationRef.current = internals.nextCorrelationId();
|
|
226
239
|
}
|
|
227
240
|
loadedInCycleRef.current = true;
|
|
228
|
-
const loadedAt =
|
|
241
|
+
const loadedAt = clock();
|
|
229
242
|
move('ready');
|
|
230
243
|
emit({
|
|
231
244
|
type: 'loaded',
|
|
@@ -236,32 +249,32 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
|
|
|
236
249
|
durationMs: loadedAt - startedAtRef.current,
|
|
237
250
|
});
|
|
238
251
|
startedAtRef.current = loadedAt;
|
|
239
|
-
}, [
|
|
252
|
+
}, [internals, clock, emit, move, placementKey]);
|
|
240
253
|
|
|
241
254
|
const handleFailed = useCallback(
|
|
242
|
-
(error: unknown) => {
|
|
243
|
-
const normalized = normalizeProviderError(
|
|
244
|
-
|
|
245
|
-
placementKey,
|
|
246
|
-
|
|
247
|
-
|
|
255
|
+
(error: unknown, kind?: EngineFailureKind) => {
|
|
256
|
+
const normalized = normalizeProviderError(
|
|
257
|
+
error,
|
|
258
|
+
{ operation: 'load', placementKey, format: 'banner' },
|
|
259
|
+
kind,
|
|
260
|
+
);
|
|
248
261
|
move('failed');
|
|
249
262
|
emit({
|
|
250
263
|
type: 'load_failed',
|
|
251
|
-
timestamp:
|
|
264
|
+
timestamp: clock(),
|
|
252
265
|
placementKey,
|
|
253
266
|
format: 'banner',
|
|
254
267
|
correlationId: correlationRef.current ?? undefined,
|
|
255
268
|
error: normalized,
|
|
256
269
|
});
|
|
257
270
|
},
|
|
258
|
-
[emit, move, placementKey],
|
|
271
|
+
[clock, emit, move, placementKey],
|
|
259
272
|
);
|
|
260
273
|
|
|
261
274
|
const handleImpression = useCallback(() => {
|
|
262
275
|
emit({
|
|
263
276
|
type: 'impression',
|
|
264
|
-
timestamp:
|
|
277
|
+
timestamp: clock(),
|
|
265
278
|
placementKey,
|
|
266
279
|
format: 'banner',
|
|
267
280
|
correlationId: correlationRef.current ?? undefined,
|
|
@@ -270,17 +283,17 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
|
|
|
270
283
|
// reconciliation, so it is recorded rather than flattened (R5).
|
|
271
284
|
source: 'provider',
|
|
272
285
|
});
|
|
273
|
-
}, [emit, placementKey]);
|
|
286
|
+
}, [clock, emit, placementKey]);
|
|
274
287
|
|
|
275
288
|
const handleClicked = useCallback(() => {
|
|
276
289
|
emit({
|
|
277
290
|
type: 'click',
|
|
278
|
-
timestamp:
|
|
291
|
+
timestamp: clock(),
|
|
279
292
|
placementKey,
|
|
280
293
|
format: 'banner',
|
|
281
294
|
correlationId: correlationRef.current ?? undefined,
|
|
282
295
|
});
|
|
283
|
-
}, [emit, placementKey]);
|
|
296
|
+
}, [clock, emit, placementKey]);
|
|
284
297
|
|
|
285
298
|
// FR-041: nothing survives unmount. No retained native view, no dangling
|
|
286
299
|
// correlation, no state written into a disposed instance.
|
|
@@ -290,7 +303,7 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
|
|
|
290
303
|
};
|
|
291
304
|
}, []);
|
|
292
305
|
|
|
293
|
-
if (!
|
|
306
|
+
if (!internals || !banner) {
|
|
294
307
|
// FR-042, UX-003 — out of the tree entirely, blocking no touch.
|
|
295
308
|
return null;
|
|
296
309
|
}
|
|
@@ -318,34 +331,22 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
|
|
|
318
331
|
}
|
|
319
332
|
}
|
|
320
333
|
|
|
321
|
-
const
|
|
334
|
+
const BannerComponent = banner.Component;
|
|
322
335
|
const adUnitId = resolution?.ok ? resolution.adUnitId : null;
|
|
323
|
-
// The provider's polarity is the inverse of the consumer-facing option.
|
|
324
|
-
const requestNonPersonalizedAdsOnly = resolution?.ok ? !resolution.personalizedAds : false;
|
|
325
336
|
|
|
326
337
|
return (
|
|
327
338
|
<View style={style} onLayout={onLayout} testID={`ads-banner-${placementKey}`}>
|
|
328
|
-
{usable && adUnitId ? (
|
|
329
|
-
<
|
|
339
|
+
{usable && adUnitId && resolution?.ok ? (
|
|
340
|
+
<BannerComponent
|
|
330
341
|
key={`${placementKey}-${cycle}`}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
onAdClicked={handleClicked}
|
|
342
|
+
adUnitId={adUnitId}
|
|
343
|
+
personalizedAds={resolution.personalizedAds}
|
|
344
|
+
onLoaded={handleLoaded}
|
|
345
|
+
onFailed={handleFailed}
|
|
346
|
+
onImpression={handleImpression}
|
|
347
|
+
onClicked={handleClicked}
|
|
338
348
|
/>
|
|
339
349
|
) : null}
|
|
340
350
|
</View>
|
|
341
351
|
);
|
|
342
352
|
}
|
|
343
|
-
|
|
344
|
-
/** Exposed for tests that assert the failure taxonomy without a renderer. */
|
|
345
|
-
export function bannerUnavailableError(placementKey: string): AdsError {
|
|
346
|
-
return new AdsError('unsupported_platform', {
|
|
347
|
-
operation: 'load',
|
|
348
|
-
placementKey,
|
|
349
|
-
format: 'banner',
|
|
350
|
-
});
|
|
351
|
-
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A clock the test drives (ADS-11).
|
|
3
|
+
*
|
|
4
|
+
* Every bounded wait in this package — retry backoff, the request throttle, a
|
|
5
|
+
* consent form that never answers — is measured against a clock. Injecting this
|
|
6
|
+
* one is what lets a suite assert the behaviour at the boundary instead of
|
|
7
|
+
* sleeping and hoping.
|
|
8
|
+
*/
|
|
9
|
+
import type { Scheduler } from '../core/timers';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A clock a test can move by hand.
|
|
13
|
+
*
|
|
14
|
+
* Retry backoff, app-open expiry, the undecided-state warning, and the
|
|
15
|
+
* ad-originated-return windows are all time-dependent, and all four must be
|
|
16
|
+
* assertable exactly rather than approximately. Advancing a clock makes "two
|
|
17
|
+
* retries, 2s then 4s" a precise assertion; sleeping would make it a flaky one.
|
|
18
|
+
*
|
|
19
|
+
* Callbacks fire in due order, and a callback scheduled *during* an advance is
|
|
20
|
+
* honored within the same advance if its deadline has also passed — matching how
|
|
21
|
+
* real timers behave when work chains.
|
|
22
|
+
*/
|
|
23
|
+
export interface TestClock {
|
|
24
|
+
now(): number;
|
|
25
|
+
advance(ms: number): void;
|
|
26
|
+
readonly scheduler: Scheduler;
|
|
27
|
+
pending(): number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createTestClock(startMs = 1_000_000): TestClock {
|
|
31
|
+
let current = startMs;
|
|
32
|
+
let sequence = 0;
|
|
33
|
+
const timers = new Map<number, { at: number; callback: () => void }>();
|
|
34
|
+
|
|
35
|
+
const scheduler: Scheduler = {
|
|
36
|
+
setTimeout(callback, ms) {
|
|
37
|
+
sequence += 1;
|
|
38
|
+
timers.set(sequence, { at: current + ms, callback });
|
|
39
|
+
return sequence;
|
|
40
|
+
},
|
|
41
|
+
clearTimeout(handle) {
|
|
42
|
+
timers.delete(handle as number);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
now: () => current,
|
|
48
|
+
advance(ms) {
|
|
49
|
+
const target = current + ms;
|
|
50
|
+
// Loop rather than iterate once: a fired callback may schedule another
|
|
51
|
+
// timer that is also due before `target`, which is exactly what a retry
|
|
52
|
+
// chain does.
|
|
53
|
+
for (;;) {
|
|
54
|
+
let nextId: number | null = null;
|
|
55
|
+
let nextAt = Number.POSITIVE_INFINITY;
|
|
56
|
+
for (const [id, timer] of timers) {
|
|
57
|
+
if (timer.at <= target && timer.at < nextAt) {
|
|
58
|
+
nextAt = timer.at;
|
|
59
|
+
nextId = id;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (nextId === null) {
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
const timer = timers.get(nextId)!;
|
|
66
|
+
timers.delete(nextId);
|
|
67
|
+
current = timer.at;
|
|
68
|
+
timer.callback();
|
|
69
|
+
}
|
|
70
|
+
current = target;
|
|
71
|
+
},
|
|
72
|
+
scheduler,
|
|
73
|
+
pending: () => timers.size,
|
|
74
|
+
};
|
|
75
|
+
}
|
package/src/testing/index.ts
CHANGED
|
@@ -16,85 +16,23 @@
|
|
|
16
16
|
* so test doubles cannot reach a production bundle.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import type { FullScreenFormat, Reward, Unsubscribe } from '../config/types';
|
|
20
|
-
|
|
19
|
+
import type { AdFormat, FullScreenFormat, Reward, Unsubscribe } from '../config/types';
|
|
20
|
+
export { createTestClock, type TestClock } from './clock';
|
|
21
|
+
import { createTestClock, type TestClock } from './clock';
|
|
22
|
+
|
|
23
|
+
import { testAdUnitFor } from '../engine/testAdUnits';
|
|
21
24
|
import type {
|
|
22
25
|
AdsEngine,
|
|
23
26
|
ConsentPort,
|
|
24
27
|
EngineAdEvent,
|
|
25
28
|
EngineAdHandle,
|
|
29
|
+
EngineBannerProps,
|
|
26
30
|
EngineConsentInfo,
|
|
27
31
|
EngineConsentOptions,
|
|
28
32
|
EnginePrivacy,
|
|
29
33
|
EngineRequestOptions,
|
|
30
34
|
} from '../engine/port';
|
|
31
35
|
|
|
32
|
-
/**
|
|
33
|
-
* A clock a test can move by hand.
|
|
34
|
-
*
|
|
35
|
-
* Retry backoff, app-open expiry, the undecided-state warning, and the
|
|
36
|
-
* ad-originated-return windows are all time-dependent, and all four must be
|
|
37
|
-
* assertable exactly rather than approximately. Advancing a clock makes "two
|
|
38
|
-
* retries, 2s then 4s" a precise assertion; sleeping would make it a flaky one.
|
|
39
|
-
*
|
|
40
|
-
* Callbacks fire in due order, and a callback scheduled *during* an advance is
|
|
41
|
-
* honored within the same advance if its deadline has also passed — matching how
|
|
42
|
-
* real timers behave when work chains.
|
|
43
|
-
*/
|
|
44
|
-
export interface TestClock {
|
|
45
|
-
now(): number;
|
|
46
|
-
advance(ms: number): void;
|
|
47
|
-
readonly scheduler: Scheduler;
|
|
48
|
-
pending(): number;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function createTestClock(startMs = 1_000_000): TestClock {
|
|
52
|
-
let current = startMs;
|
|
53
|
-
let sequence = 0;
|
|
54
|
-
const timers = new Map<number, { at: number; callback: () => void }>();
|
|
55
|
-
|
|
56
|
-
const scheduler: Scheduler = {
|
|
57
|
-
setTimeout(callback, ms) {
|
|
58
|
-
sequence += 1;
|
|
59
|
-
timers.set(sequence, { at: current + ms, callback });
|
|
60
|
-
return sequence;
|
|
61
|
-
},
|
|
62
|
-
clearTimeout(handle) {
|
|
63
|
-
timers.delete(handle as number);
|
|
64
|
-
},
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
return {
|
|
68
|
-
now: () => current,
|
|
69
|
-
advance(ms) {
|
|
70
|
-
const target = current + ms;
|
|
71
|
-
// Loop rather than iterate once: a fired callback may schedule another
|
|
72
|
-
// timer that is also due before `target`, which is exactly what a retry
|
|
73
|
-
// chain does.
|
|
74
|
-
for (;;) {
|
|
75
|
-
let nextId: number | null = null;
|
|
76
|
-
let nextAt = Number.POSITIVE_INFINITY;
|
|
77
|
-
for (const [id, timer] of timers) {
|
|
78
|
-
if (timer.at <= target && timer.at < nextAt) {
|
|
79
|
-
nextAt = timer.at;
|
|
80
|
-
nextId = id;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
if (nextId === null) {
|
|
84
|
-
break;
|
|
85
|
-
}
|
|
86
|
-
const timer = timers.get(nextId)!;
|
|
87
|
-
timers.delete(nextId);
|
|
88
|
-
current = timer.at;
|
|
89
|
-
timer.callback();
|
|
90
|
-
}
|
|
91
|
-
current = target;
|
|
92
|
-
},
|
|
93
|
-
scheduler,
|
|
94
|
-
pending: () => timers.size,
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
|
|
98
36
|
export type FakeConsentCall =
|
|
99
37
|
| { readonly kind: 'requestInfoUpdate'; readonly options: EngineConsentOptions }
|
|
100
38
|
| { readonly kind: 'showFormIfRequired' }
|
|
@@ -212,6 +150,11 @@ function nextMicrotask(task: () => void): void {
|
|
|
212
150
|
Promise.resolve().then(task);
|
|
213
151
|
}
|
|
214
152
|
|
|
153
|
+
/** The fake engine's banner component. */
|
|
154
|
+
function FakeBanner(_props: EngineBannerProps): null {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
215
158
|
export function createFakeAdsEngine(options: FakeEngineOptions = {}): FakeAdsEngine {
|
|
216
159
|
const calls: FakeEngineCall[] = [];
|
|
217
160
|
const consentCalls: FakeConsentCall[] = [];
|
|
@@ -353,6 +296,14 @@ export function createFakeAdsEngine(options: FakeEngineOptions = {}): FakeAdsEng
|
|
|
353
296
|
return options.available !== false;
|
|
354
297
|
},
|
|
355
298
|
|
|
299
|
+
// Renders nothing. A test finds it in the tree by its `adUnitId` prop and
|
|
300
|
+
// calls `onLoaded`, `onFailed`, `onImpression` or `onClicked` itself, which
|
|
301
|
+
// is how a banner is driven without mocking a native module (ADS-12).
|
|
302
|
+
banner: {
|
|
303
|
+
isAvailable: () => options.available !== false,
|
|
304
|
+
Component: FakeBanner,
|
|
305
|
+
},
|
|
306
|
+
|
|
356
307
|
async initialize(privacy) {
|
|
357
308
|
record({ kind: 'initialize', privacy });
|
|
358
309
|
if (options.failInitialize !== undefined) {
|
|
@@ -473,3 +424,13 @@ export function createFakeAdsEngine(options: FakeEngineOptions = {}): FakeAdsEng
|
|
|
473
424
|
|
|
474
425
|
export { adsEngineChecks, runAdsEngineConformance } from './conformance';
|
|
475
426
|
export type { AdsEngineHarness, AdsEngineSubject } from './conformance';
|
|
427
|
+
|
|
428
|
+
/** Google's official test ad unit for each format, as the package substitutes them (FR-011). */
|
|
429
|
+
export function allTestAdUnits(): Readonly<Record<AdFormat, string>> {
|
|
430
|
+
return {
|
|
431
|
+
banner: testAdUnitFor('banner'),
|
|
432
|
+
interstitial: testAdUnitFor('interstitial'),
|
|
433
|
+
rewarded: testAdUnitFor('rewarded'),
|
|
434
|
+
app_open: testAdUnitFor('app_open'),
|
|
435
|
+
};
|
|
436
|
+
}
|