@roku-sdk/ads 0.0.0-stage → 2.19.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.
@@ -0,0 +1,735 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { describe, expect, test } from "@jest/globals";
5
+ import { registerInternStringApi } from "@roku-sdk/common/string-interner";
6
+ import { transactionManager } from "@roku-sdk/common/transaction";
7
+ import type { DomNode, InternStringApi, StructuredDataApi, TreeManipulationApi } from "@roku-sdk/interfaces";
8
+ import type { Component } from "@roku-sdk/solid";
9
+ import { createSignal, makeSolidRenderer } from "@roku-sdk/solid";
10
+ import { createDomNode } from "@roku-sdk/solid/src/dom-node";
11
+
12
+ import { Ads, type AdsCommand, type IAdsProps } from "../ads";
13
+
14
+ type Captured = Record<string, CapturedEntry>;
15
+
16
+ type CapturedEntry =
17
+ | { kind: "bool"; value: boolean }
18
+ | { kind: "string"; value: string }
19
+ | { kind: "object"; value: Record<string, unknown> }
20
+ | { kind: "nodeRef"; value: string }
21
+ | { kind: "eventHandler" };
22
+
23
+ const commitEarly = transactionManager.commitEarly;
24
+
25
+ const SAMPLE_AD_URL =
26
+ "https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/vmap_ad_samples&output=vmap";
27
+
28
+ function captureAdsProps(buildProps: () => IAdsProps): { tagName: string; props: Captured } {
29
+ const internForward = new Map<string, object>();
30
+ const internReverse = new Map<object, string>();
31
+ let internRef = 0;
32
+ const mockIntern: InternStringApi = {
33
+ internString: (str: string): object => {
34
+ const existing = internForward.get(str);
35
+ if (existing) return existing;
36
+ const ref = { id: ++internRef };
37
+ internForward.set(str, ref);
38
+ internReverse.set(ref, str);
39
+ return ref;
40
+ }
41
+ };
42
+ registerInternStringApi(mockIntern);
43
+
44
+ const resolveInterned = (ref: unknown): string => {
45
+ if (ref && typeof ref === "object") {
46
+ const s = internReverse.get(ref as object);
47
+ if (s !== undefined) return s;
48
+ }
49
+ return String(ref);
50
+ };
51
+
52
+ const nodeTag = new WeakMap<object, string>();
53
+ let nodeCounter = 0;
54
+
55
+ const mockSd: StructuredDataApi = {
56
+ createObject: () => ({}),
57
+ createArray: () => [],
58
+ insertBool: (o, p, v) => {
59
+ (o as Record<string, unknown>)[resolveInterned(p)] = v;
60
+ },
61
+ insertNull: (o, p) => {
62
+ (o as Record<string, unknown>)[resolveInterned(p)] = null;
63
+ },
64
+ insertInt: (o, p, v) => {
65
+ (o as Record<string, unknown>)[resolveInterned(p)] = v;
66
+ },
67
+ insertFloat: (o, p, v) => {
68
+ (o as Record<string, unknown>)[resolveInterned(p)] = v;
69
+ },
70
+ insertString: (o, p, v) => {
71
+ (o as Record<string, unknown>)[resolveInterned(p)] = v;
72
+ },
73
+ insertObject: (o, p, v) => {
74
+ (o as Record<string, unknown>)[resolveInterned(p)] = v;
75
+ },
76
+ insertArray: (o, p, v) => {
77
+ (o as Record<string, unknown>)[resolveInterned(p)] = v;
78
+ },
79
+ insertNodeRef: () => undefined,
80
+ appendBool: (a, v) => {
81
+ (a as unknown[]).push(v);
82
+ },
83
+ appendNull: a => {
84
+ (a as unknown[]).push(null);
85
+ },
86
+ appendInt: (a, v) => {
87
+ (a as unknown[]).push(v);
88
+ },
89
+ appendFloat: (a, v) => {
90
+ (a as unknown[]).push(v);
91
+ },
92
+ appendString: (a, v) => {
93
+ (a as unknown[]).push(typeof v === "string" ? v : resolveInterned(v));
94
+ },
95
+ appendObject: (a, v) => {
96
+ (a as unknown[]).push(v);
97
+ },
98
+ appendArray: (a, v) => {
99
+ (a as unknown[]).push(v);
100
+ },
101
+ appendNodeRef: () => undefined,
102
+ getArray: () => undefined,
103
+ getObject: () => undefined
104
+ };
105
+
106
+ type Entry = { nodeRef: object; propName: string; entry: CapturedEntry };
107
+ const allCalls: Entry[] = [];
108
+ let rootTagName = "";
109
+
110
+ const push = (nodeRef: unknown, propRef: unknown, entry: CapturedEntry) => {
111
+ if (!nodeRef || typeof nodeRef !== "object") return;
112
+ allCalls.push({ nodeRef: nodeRef as object, propName: resolveInterned(propRef), entry });
113
+ };
114
+
115
+ const mockTm: TreeManipulationApi = {
116
+ createNode: (_anchor, tagRef) => {
117
+ const ref: object = { id: ++nodeCounter };
118
+ const tagName = resolveInterned(tagRef);
119
+ nodeTag.set(ref, tagName);
120
+ if (!rootTagName) rootTagName = tagName;
121
+ return ref;
122
+ },
123
+ appendNode: () => undefined,
124
+ insertNodeBefore: () => undefined,
125
+ removeNode: () => undefined,
126
+ setNodePropNull: (_a, n, p) => push(n, p, { kind: "string", value: "null" }),
127
+ setNodePropBool: (_a, n, p, v) => push(n, p, { kind: "bool", value: v }),
128
+ setNodePropInt: (_a, n, p, v) => push(n, p, { kind: "string", value: String(v) }),
129
+ setNodePropFloat: (_a, n, p, v) => push(n, p, { kind: "string", value: String(v) }),
130
+ setNodePropString: (_a, n, p, v) => push(n, p, { kind: "string", value: v }),
131
+ setNodePropObject: (_a, n, p, v) => push(n, p, { kind: "object", value: v as Record<string, unknown> }),
132
+ setNodePropArray: () => undefined,
133
+ setNodePropNodeRef: (_a, n, p, v) => {
134
+ const refId = v && typeof v === "object" && "id" in v ? String((v as { id: number }).id) : "unknown";
135
+ push(n, p, { kind: "nodeRef", value: `<ref:${refId}>` });
136
+ },
137
+ removeNodeProp: () => undefined,
138
+ updateNodeProps: () => undefined,
139
+ removeNodeProps: () => undefined,
140
+ registerFieldChangeHandler: (_a, n, p) => push(n, p, { kind: "eventHandler" }),
141
+ unregisterFieldHandler: () => undefined,
142
+ setFocus: () => undefined
143
+ };
144
+
145
+ const { renderer } = makeSolidRenderer(mockTm, mockSd, mockSd);
146
+ const anchorName = { id: 0 };
147
+
148
+ const anchorNode: DomNode = { r: {}, n: anchorName } as DomNode;
149
+ const props = buildProps();
150
+ const dispose = renderer.render((() => <Ads {...props} />) as unknown as () => DomNode, anchorNode);
151
+ commitEarly();
152
+
153
+ const rootDom = anchorNode.c && anchorNode.c[0];
154
+ const rootNodeRef = rootDom?.r as object | undefined;
155
+
156
+ dispose();
157
+
158
+ const captured: Captured = {};
159
+ if (rootNodeRef) {
160
+ for (const { nodeRef, propName, entry } of allCalls) {
161
+ if (nodeRef === rootNodeRef) captured[propName] = entry;
162
+ }
163
+ }
164
+
165
+ const sorted: Captured = {};
166
+ for (const key of Object.keys(captured).sort()) {
167
+ sorted[key] = captured[key];
168
+ }
169
+
170
+ return { tagName: rootTagName, props: sorted };
171
+ }
172
+
173
+ function buildSampleProps(): IAdsProps {
174
+ const adView = createDomNode("rectangle", { id: 100 });
175
+ const contentView = createDomNode("video", { id: 101 });
176
+
177
+ return {
178
+ debugOutput: true,
179
+ command: "getAds",
180
+ adUrl: SAMPLE_AD_URL,
181
+ adView,
182
+ contentView,
183
+ onAdsState: () => {},
184
+ onGetAdsResult: () => {},
185
+ onShowAdsResult: () => {}
186
+ };
187
+ }
188
+
189
+ function buildSsaiProps(): IAdsProps {
190
+ const contentView = createDomNode("video", { id: 201 });
191
+
192
+ return {
193
+ debugOutput: true,
194
+ command: "stitchedAdsInit",
195
+ adUrl: SAMPLE_AD_URL,
196
+ contentView,
197
+ onAdsState: () => {},
198
+ onGetAdsResult: () => {},
199
+ onStitchedAdResult: () => {}
200
+ };
201
+ }
202
+
203
+ const SAMPLE_CONTENT_METADATA = {
204
+ url: "http://example.com/content.m3u8",
205
+ length: 600
206
+ };
207
+
208
+ function buildCsasProps(): IAdsProps {
209
+ const adView = createDomNode("rectangle", { id: 401 });
210
+
211
+ return {
212
+ debugOutput: true,
213
+ command: "constructStitchedStream",
214
+ adUrl: SAMPLE_AD_URL,
215
+ adView,
216
+ contentMetadata: SAMPLE_CONTENT_METADATA,
217
+ onAdsState: () => {},
218
+ onGetAdsResult: () => {},
219
+ onConstructStitchedStreamResult: () => {},
220
+ onRenderStitchedStreamResult: () => {}
221
+ };
222
+ }
223
+
224
+ const SAMPLE_CONTENT_GENRE = "Entertainment";
225
+ const SAMPLE_CONTENT_ID = "CSASAdSample";
226
+
227
+ function buildAudienceMeasurementProps(): IAdsProps {
228
+ return {
229
+ enableAdMeasurements: true,
230
+ contentGenre: SAMPLE_CONTENT_GENRE,
231
+ contentId: SAMPLE_CONTENT_ID
232
+ };
233
+ }
234
+
235
+ describe("Ads", () => {
236
+ test("renders the ads SceneGraph Task node", () => {
237
+ const { tagName } = captureAdsProps(buildSampleProps);
238
+ expect(tagName).toBe("ads");
239
+ });
240
+
241
+ test("forwards scalar props to the underlying ads node", () => {
242
+ const { props } = captureAdsProps(buildSampleProps);
243
+
244
+ expect(props).toMatchObject({
245
+ adUrl: { kind: "string", value: SAMPLE_AD_URL },
246
+ command: { kind: "string", value: "getAds" },
247
+ debugOutput: { kind: "bool", value: true }
248
+ });
249
+ });
250
+
251
+ test("forwards node refs for adView and contentView", () => {
252
+ const { props } = captureAdsProps(buildSampleProps);
253
+
254
+ expect(props.adView).toEqual({ kind: "nodeRef", value: "<ref:100>" });
255
+ expect(props.contentView).toEqual({ kind: "nodeRef", value: "<ref:101>" });
256
+ });
257
+
258
+ test("registers RAF callback fields on the ads node", () => {
259
+ const { props } = captureAdsProps(buildSampleProps);
260
+
261
+ expect(props.adsState).toEqual({ kind: "eventHandler" });
262
+ expect(props.getAdsResult).toEqual({ kind: "eventHandler" });
263
+ expect(props.showAdsResult).toEqual({ kind: "eventHandler" });
264
+ });
265
+
266
+ test("forwards adCounter as the assocarray RAF reads its ad counter from", () => {
267
+ const { props } = captureAdsProps(() => ({
268
+ command: "showAds",
269
+ adCounter: { start: 1, total: 4 }
270
+ }));
271
+
272
+ expect(props.adCounter).toEqual({ kind: "object", value: { start: 1, total: 4 } });
273
+ });
274
+
275
+ test("omits adCounter when it is not given, so RAF keeps its own count", () => {
276
+ const { props } = captureAdsProps(() => ({ command: "showAds" }));
277
+
278
+ expect(props.adCounter).toBeUndefined();
279
+ });
280
+
281
+ describe("CSAS", () => {
282
+ test("forwards constructStitchedStream command to the underlying ads node", () => {
283
+ const { props } = captureAdsProps(buildCsasProps);
284
+
285
+ expect(props.command).toEqual({ kind: "string", value: "constructStitchedStream" });
286
+ });
287
+
288
+ test("forwards contentMetadata for stitched stream construction", () => {
289
+ const { props } = captureAdsProps(buildCsasProps);
290
+
291
+ expect(props.contentMetadata).toEqual({ kind: "object", value: SAMPLE_CONTENT_METADATA });
292
+ });
293
+
294
+ test("forwards adView for stitched stream rendering", () => {
295
+ const { props } = captureAdsProps(buildCsasProps);
296
+
297
+ expect(props.adView).toEqual({ kind: "nodeRef", value: "<ref:401>" });
298
+ });
299
+
300
+ test("registers constructStitchedStreamResult callback on the ads node", () => {
301
+ const { props } = captureAdsProps(buildCsasProps);
302
+
303
+ expect(props.constructStitchedStreamResult).toEqual({ kind: "eventHandler" });
304
+ });
305
+
306
+ test("registers renderStitchedStreamResult callback on the ads node", () => {
307
+ const { props } = captureAdsProps(buildCsasProps);
308
+
309
+ expect(props.renderStitchedStreamResult).toEqual({ kind: "eventHandler" });
310
+ });
311
+
312
+ test("forwards renderStitchedStream command to the underlying ads node", () => {
313
+ const { props } = captureAdsProps(() => ({
314
+ ...buildCsasProps(),
315
+ command: "renderStitchedStream"
316
+ }));
317
+
318
+ expect(props.command).toEqual({ kind: "string", value: "renderStitchedStream" });
319
+ });
320
+
321
+ test("forwards only CSAS-relevant props when contentView and showAds handlers are omitted", () => {
322
+ const { props } = captureAdsProps(() => ({
323
+ command: "constructStitchedStream",
324
+ adView: createDomNode("rectangle", { id: 501 }),
325
+ contentMetadata: SAMPLE_CONTENT_METADATA,
326
+ onConstructStitchedStreamResult: () => {},
327
+ onRenderStitchedStreamResult: () => {}
328
+ }));
329
+
330
+ expect(Object.keys(props).sort()).toEqual([
331
+ "adView",
332
+ "command",
333
+ "constructStitchedStreamResult",
334
+ "contentMetadata",
335
+ "renderStitchedStreamResult"
336
+ ]);
337
+ expect(props.command).toEqual({ kind: "string", value: "constructStitchedStream" });
338
+ expect(props.adView).toEqual({ kind: "nodeRef", value: "<ref:501>" });
339
+ expect(props.contentMetadata).toEqual({ kind: "object", value: SAMPLE_CONTENT_METADATA });
340
+ expect(props.constructStitchedStreamResult).toEqual({ kind: "eventHandler" });
341
+ expect(props.renderStitchedStreamResult).toEqual({ kind: "eventHandler" });
342
+ });
343
+ });
344
+
345
+ describe("audience measurement", () => {
346
+ test("forwards enableAdMeasurements to the underlying ads node", () => {
347
+ const { props } = captureAdsProps(() => ({
348
+ ...buildAudienceMeasurementProps(),
349
+ enableAdMeasurements: true
350
+ }));
351
+
352
+ expect(props.enableAdMeasurements).toEqual({ kind: "bool", value: true });
353
+ });
354
+
355
+ test("forwards enableAdMeasurements=false when measurement is disabled", () => {
356
+ const { props } = captureAdsProps(() => ({
357
+ ...buildAudienceMeasurementProps(),
358
+ enableAdMeasurements: false
359
+ }));
360
+
361
+ expect(props.enableAdMeasurements).toEqual({ kind: "bool", value: false });
362
+ });
363
+
364
+ test("forwards contentGenre to the underlying ads node", () => {
365
+ const { props } = captureAdsProps(buildAudienceMeasurementProps);
366
+
367
+ expect(props.contentGenre).toEqual({ kind: "string", value: SAMPLE_CONTENT_GENRE });
368
+ });
369
+
370
+ test("forwards contentId to the underlying ads node", () => {
371
+ const { props } = captureAdsProps(buildAudienceMeasurementProps);
372
+
373
+ expect(props.contentId).toEqual({ kind: "string", value: SAMPLE_CONTENT_ID });
374
+ });
375
+
376
+ test("forwards all audience measurement props together", () => {
377
+ const { props } = captureAdsProps(buildAudienceMeasurementProps);
378
+
379
+ expect(props).toMatchObject({
380
+ enableAdMeasurements: { kind: "bool", value: true },
381
+ contentGenre: { kind: "string", value: SAMPLE_CONTENT_GENRE },
382
+ contentId: { kind: "string", value: SAMPLE_CONTENT_ID }
383
+ });
384
+ });
385
+
386
+ test("forwards only audience measurement props when other props are omitted", () => {
387
+ const { props } = captureAdsProps(buildAudienceMeasurementProps);
388
+
389
+ expect(Object.keys(props).sort()).toEqual(["contentGenre", "contentId", "enableAdMeasurements"]);
390
+ });
391
+
392
+ test("updates audience measurement props reactively when signals change", () => {
393
+ const internForward = new Map<string, object>();
394
+ const internReverse = new Map<object, string>();
395
+ let internRef = 0;
396
+ registerInternStringApi({
397
+ internString: (str: string): object => {
398
+ const existing = internForward.get(str);
399
+ if (existing) return existing;
400
+ const ref = { id: ++internRef };
401
+ internForward.set(str, ref);
402
+ internReverse.set(ref, str);
403
+ return ref;
404
+ }
405
+ });
406
+
407
+ const resolveInterned = (ref: unknown): string => {
408
+ if (ref && typeof ref === "object") {
409
+ const s = internReverse.get(ref as object);
410
+ if (s !== undefined) return s;
411
+ }
412
+ return String(ref);
413
+ };
414
+
415
+ const boolValues: boolean[] = [];
416
+ const stringValues: Record<string, string[]> = {
417
+ contentGenre: [],
418
+ contentId: []
419
+ };
420
+
421
+ const mockSd: StructuredDataApi = {
422
+ createObject: () => ({}),
423
+ createArray: () => [],
424
+ insertBool: () => undefined,
425
+ insertNull: () => undefined,
426
+ insertInt: () => undefined,
427
+ insertFloat: () => undefined,
428
+ insertString: () => undefined,
429
+ insertObject: () => undefined,
430
+ insertArray: () => undefined,
431
+ insertNodeRef: () => undefined,
432
+ appendBool: () => undefined,
433
+ appendNull: () => undefined,
434
+ appendInt: () => undefined,
435
+ appendFloat: () => undefined,
436
+ appendString: () => undefined,
437
+ appendObject: () => undefined,
438
+ appendArray: () => undefined,
439
+ appendNodeRef: () => undefined,
440
+ getArray: () => undefined,
441
+ getObject: () => undefined
442
+ };
443
+
444
+ const mockTm: TreeManipulationApi = {
445
+ createNode: (_anchor, tagRef) => ({ id: 1, tag: resolveInterned(tagRef) }),
446
+ appendNode: () => undefined,
447
+ insertNodeBefore: () => undefined,
448
+ removeNode: () => undefined,
449
+ setNodePropNull: () => undefined,
450
+ setNodePropBool: (_a, _n, p, v) => {
451
+ if (resolveInterned(p) === "enableAdMeasurements") boolValues.push(v);
452
+ },
453
+ setNodePropInt: () => undefined,
454
+ setNodePropFloat: () => undefined,
455
+ setNodePropString: (_a, _n, p, v) => {
456
+ const prop = resolveInterned(p);
457
+ if (prop in stringValues) stringValues[prop].push(v);
458
+ },
459
+ setNodePropObject: () => undefined,
460
+ setNodePropArray: () => undefined,
461
+ setNodePropNodeRef: () => undefined,
462
+ removeNodeProp: () => undefined,
463
+ updateNodeProps: () => undefined,
464
+ removeNodeProps: () => undefined,
465
+ registerFieldChangeHandler: () => undefined,
466
+ unregisterFieldHandler: () => undefined,
467
+ setFocus: () => undefined
468
+ };
469
+
470
+ const { renderer } = makeSolidRenderer(mockTm, mockSd, mockSd);
471
+ const anchorName = { id: 0 };
472
+
473
+ const [enableAdMeasurements, setEnableAdMeasurements] = createSignal(false);
474
+ const [contentGenre, setContentGenre] = createSignal("");
475
+ const [contentId, setContentId] = createSignal("");
476
+ const anchorNode: DomNode = { r: {}, n: anchorName } as DomNode;
477
+ const dispose = renderer.render(
478
+ (() => (
479
+ <Ads
480
+ enableAdMeasurements={enableAdMeasurements()}
481
+ contentGenre={contentGenre()}
482
+ contentId={contentId()}
483
+ />
484
+ )) as unknown as () => DomNode,
485
+ anchorNode
486
+ );
487
+
488
+ commitEarly();
489
+ expect(boolValues).toEqual([false]);
490
+ expect(stringValues.contentGenre).toEqual([""]);
491
+ expect(stringValues.contentId).toEqual([""]);
492
+
493
+ setEnableAdMeasurements(true);
494
+ setContentGenre(SAMPLE_CONTENT_GENRE);
495
+ setContentId(SAMPLE_CONTENT_ID);
496
+ commitEarly();
497
+ expect(boolValues).toEqual([false, true]);
498
+ expect(stringValues.contentGenre).toEqual(["", SAMPLE_CONTENT_GENRE]);
499
+ expect(stringValues.contentId).toEqual(["", SAMPLE_CONTENT_ID]);
500
+
501
+ dispose();
502
+ });
503
+ });
504
+
505
+ describe("SSAI", () => {
506
+ test("forwards stitchedAdsInit command to the underlying ads node", () => {
507
+ const { props } = captureAdsProps(buildSsaiProps);
508
+
509
+ expect(props.command).toEqual({ kind: "string", value: "stitchedAdsInit" });
510
+ });
511
+
512
+ test("forwards contentView without adView for stitched playback", () => {
513
+ const { props } = captureAdsProps(buildSsaiProps);
514
+
515
+ expect(props.contentView).toEqual({ kind: "nodeRef", value: "<ref:201>" });
516
+ expect(props.adView).toBeUndefined();
517
+ });
518
+
519
+ test("registers stitchedAdResult callback on the ads node", () => {
520
+ const { props } = captureAdsProps(buildSsaiProps);
521
+
522
+ expect(props.stitchedAdResult).toEqual({ kind: "eventHandler" });
523
+ });
524
+
525
+ test("registers stitchedAdsInitResult callback on the ads node", () => {
526
+ const { props } = captureAdsProps(() => ({
527
+ command: "stitchedAdsInit",
528
+ contentView: createDomNode("video", { id: 301 }),
529
+ onStitchedAdsInitResult: () => {}
530
+ }));
531
+
532
+ expect(props.stitchedAdsInitResult).toEqual({ kind: "eventHandler" });
533
+ });
534
+
535
+ test("forwards only SSAI-relevant props when adView and showAds handlers are omitted", () => {
536
+ const { props } = captureAdsProps(() => ({
537
+ command: "stitchedAdsInit",
538
+ contentView: createDomNode("video", { id: 301 }),
539
+ onStitchedAdResult: () => {}
540
+ }));
541
+
542
+ expect(Object.keys(props).sort()).toEqual(["command", "contentView", "stitchedAdResult"]);
543
+ expect(props.command).toEqual({ kind: "string", value: "stitchedAdsInit" });
544
+ expect(props.contentView).toEqual({ kind: "nodeRef", value: "<ref:301>" });
545
+ expect(props.stitchedAdResult).toEqual({ kind: "eventHandler" });
546
+ });
547
+ });
548
+
549
+ test("forwards only provided props", () => {
550
+ const { props } = captureAdsProps(() => ({ command: "showAds" }));
551
+
552
+ expect(Object.keys(props)).toEqual(["command"]);
553
+ expect(props.command).toEqual({ kind: "string", value: "showAds" });
554
+ });
555
+
556
+ test("updates command reactively when the prop signal changes", () => {
557
+ const { commandValues, setCommand, commit, dispose } = renderWithCommandRecorder("getAds");
558
+
559
+ commit();
560
+ expect(commandValues).toEqual(["getAds"]);
561
+
562
+ setCommand("showAds");
563
+ commit();
564
+ expect(commandValues).toEqual(["getAds", "showAds"]);
565
+
566
+ setCommand("stitchedAdsInit");
567
+ commit();
568
+ expect(commandValues).toEqual(["getAds", "showAds", "stitchedAdsInit"]);
569
+
570
+ setCommand("constructStitchedStream");
571
+ commit();
572
+ expect(commandValues).toEqual(["getAds", "showAds", "stitchedAdsInit", "constructStitchedStream"]);
573
+
574
+ setCommand("renderStitchedStream");
575
+ commit();
576
+ expect(commandValues).toEqual([
577
+ "getAds",
578
+ "showAds",
579
+ "stitchedAdsInit",
580
+ "constructStitchedStream",
581
+ "renderStitchedStream"
582
+ ]);
583
+
584
+ dispose();
585
+ });
586
+
587
+ /**
588
+ * The repeat is what `csai.tsx` guards by resetting through `"none"`, and
589
+ * until now nothing measured it. The first test is the control: without it,
590
+ * a harness that had lost its reactivity would pass the second one for the
591
+ * wrong reason.
592
+ */
593
+ describe("a repeated command value", () => {
594
+ test("reaches the node again when the value differs", () => {
595
+ const { commandValues, setCommand, commit, dispose } = renderWithCommandRecorder("showAds");
596
+
597
+ commit();
598
+ setCommand("getAds");
599
+ commit();
600
+
601
+ expect(commandValues).toEqual(["showAds", "getAds"]);
602
+
603
+ dispose();
604
+ });
605
+
606
+ test("is not written a second time", () => {
607
+ const { commandValues, setCommand, commit, dispose } = renderWithCommandRecorder("showAds");
608
+
609
+ commit();
610
+ setCommand("showAds");
611
+ commit();
612
+
613
+ expect(commandValues).toEqual(["showAds"]);
614
+
615
+ dispose();
616
+ });
617
+
618
+ test("lands once it is reset through none", () => {
619
+ const { commandValues, setCommand, commit, dispose } = renderWithCommandRecorder("showAds");
620
+
621
+ commit();
622
+ setCommand("none");
623
+ commit();
624
+ setCommand("showAds");
625
+ commit();
626
+
627
+ expect(commandValues).toEqual(["showAds", "none", "showAds"]);
628
+
629
+ dispose();
630
+ });
631
+ });
632
+ });
633
+
634
+ /**
635
+ * Renders `<Ads>` with a signal-driven `command` and records every value that
636
+ * reaches the underlying node, in order. Ordered writes are what distinguish
637
+ * "written twice" from "written once", which the prop-capture harness above
638
+ * cannot see because it keeps only the last value per prop.
639
+ */
640
+ function renderWithCommandRecorder(initial: AdsCommand): {
641
+ commandValues: string[];
642
+ setCommand: (value: AdsCommand) => void;
643
+ commit: () => void;
644
+ dispose: () => void;
645
+ } {
646
+ const internForward = new Map<string, object>();
647
+ const internReverse = new Map<object, string>();
648
+ let internRef = 0;
649
+ registerInternStringApi({
650
+ internString: (str: string): object => {
651
+ const existing = internForward.get(str);
652
+ if (existing) return existing;
653
+ const ref = { id: ++internRef };
654
+ internForward.set(str, ref);
655
+ internReverse.set(ref, str);
656
+ return ref;
657
+ }
658
+ });
659
+
660
+ const resolveInterned = (ref: unknown): string => {
661
+ if (ref && typeof ref === "object") {
662
+ const s = internReverse.get(ref as object);
663
+ if (s !== undefined) return s;
664
+ }
665
+ return String(ref);
666
+ };
667
+
668
+ const commandValues: string[] = [];
669
+ const mockSd: StructuredDataApi = {
670
+ createObject: () => ({}),
671
+ createArray: () => [],
672
+ insertBool: () => undefined,
673
+ insertNull: () => undefined,
674
+ insertInt: () => undefined,
675
+ insertFloat: () => undefined,
676
+ insertString: () => undefined,
677
+ insertObject: () => undefined,
678
+ insertArray: () => undefined,
679
+ insertNodeRef: () => undefined,
680
+ appendBool: () => undefined,
681
+ appendNull: () => undefined,
682
+ appendInt: () => undefined,
683
+ appendFloat: () => undefined,
684
+ appendString: () => undefined,
685
+ appendObject: () => undefined,
686
+ appendArray: () => undefined,
687
+ appendNodeRef: () => undefined,
688
+ getArray: () => undefined,
689
+ getObject: () => undefined
690
+ };
691
+
692
+ const mockTm: TreeManipulationApi = {
693
+ createNode: (_anchor, tagRef) => ({ id: 1, tag: resolveInterned(tagRef) }),
694
+ appendNode: () => undefined,
695
+ insertNodeBefore: () => undefined,
696
+ removeNode: () => undefined,
697
+ setNodePropNull: () => undefined,
698
+ setNodePropBool: () => undefined,
699
+ setNodePropInt: () => undefined,
700
+ setNodePropFloat: () => undefined,
701
+ setNodePropString: (_a, _n, p, v) => {
702
+ if (resolveInterned(p) === "command") commandValues.push(v);
703
+ },
704
+ setNodePropObject: () => undefined,
705
+ setNodePropArray: () => undefined,
706
+ setNodePropNodeRef: () => undefined,
707
+ removeNodeProp: () => undefined,
708
+ updateNodeProps: () => undefined,
709
+ removeNodeProps: () => undefined,
710
+ registerFieldChangeHandler: () => undefined,
711
+ unregisterFieldHandler: () => undefined,
712
+ setFocus: () => undefined
713
+ };
714
+
715
+ const { renderer } = makeSolidRenderer(mockTm, mockSd, mockSd);
716
+ const anchorName = { id: 0 };
717
+
718
+ const [command, setCommand] = createSignal<AdsCommand>(initial);
719
+ const anchorNode: DomNode = { r: {}, n: anchorName } as DomNode;
720
+ const dispose = renderer.render((() => <Ads command={command()} />) as unknown as () => DomNode, anchorNode);
721
+
722
+ return {
723
+ commandValues,
724
+ setCommand: (value: AdsCommand) => setCommand(value),
725
+ commit: commitEarly,
726
+ dispose
727
+ };
728
+ }
729
+
730
+ describe("Ads exports", () => {
731
+ test("Ads is a SolidJS component function", () => {
732
+ expect(typeof Ads).toBe("function");
733
+ expect((Ads as Component<IAdsProps>).length).toBe(1);
734
+ });
735
+ });