@akinon/pz-theme 2.0.127-rc.0 → 2.0.128-beta.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.
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { render, screen } from '@testing-library/react';
2
+ import { act, render, screen } from '@testing-library/react';
3
3
  import { renderToStaticMarkup } from 'react-dom/server';
4
4
  import VideoBlock from '../video-block';
5
5
 
@@ -41,6 +41,105 @@ const renderBlock = (value: any, properties?: any, styles?: any) =>
41
41
  />
42
42
  );
43
43
 
44
+ /**
45
+ * Stand the test in a browser whose owner asked for less motion.
46
+ *
47
+ * `withViewport` cannot answer this one: it only ever matches a `max-width`
48
+ * query, which is exactly what keeps every other test in this file from arming
49
+ * reduced motion by accident.
50
+ */
51
+ const withReducedMotion = (run: () => void) => {
52
+ const original = window.matchMedia;
53
+ window.matchMedia = ((query: string) => ({
54
+ matches: query.includes('prefers-reduced-motion'),
55
+ media: query,
56
+ onchange: null,
57
+ addListener: () => {},
58
+ removeListener: () => {},
59
+ addEventListener: () => {},
60
+ removeEventListener: () => {},
61
+ dispatchEvent: () => false
62
+ })) as unknown as typeof window.matchMedia;
63
+ try {
64
+ run();
65
+ } finally {
66
+ window.matchMedia = original;
67
+ }
68
+ };
69
+
70
+ /**
71
+ * A recording stand-in for IntersectionObserver.
72
+ *
73
+ * `jest/setup.ts` installs a NoopObserver so nothing crashes on mount, which
74
+ * also means an observer-driven feature is INERT unless the suite brings its
75
+ * own. This is the fake `listing-controls.test.tsx` already uses: it keeps the
76
+ * options it was built with, the elements it was handed and whether it was
77
+ * disconnected, so a test can ask all three.
78
+ */
79
+ class MockObserver {
80
+ static instances: MockObserver[] = [];
81
+ callback: IntersectionObserverCallback;
82
+ options: IntersectionObserverInit | undefined;
83
+ elements: Element[] = [];
84
+ disconnected = false;
85
+
86
+ constructor(
87
+ callback: IntersectionObserverCallback,
88
+ options?: IntersectionObserverInit
89
+ ) {
90
+ this.callback = callback;
91
+ this.options = options;
92
+ MockObserver.instances.push(this);
93
+ }
94
+
95
+ observe(element: Element) {
96
+ this.elements.push(element);
97
+ }
98
+
99
+ unobserve() {}
100
+
101
+ disconnect() {
102
+ this.disconnected = true;
103
+ }
104
+
105
+ takeRecords() {
106
+ return [];
107
+ }
108
+
109
+ /** Deliver one entry, the way a real observer does when the page scrolls. */
110
+ fire(isIntersecting: boolean) {
111
+ act(() => {
112
+ this.callback(
113
+ [{ isIntersecting } as IntersectionObserverEntry],
114
+ this as unknown as IntersectionObserver
115
+ );
116
+ });
117
+ }
118
+
119
+ static get live() {
120
+ return MockObserver.instances.filter((instance) => !instance.disconnected);
121
+ }
122
+ }
123
+
124
+ /**
125
+ * The boxes the control sits in, named the way the source names them:
126
+ *
127
+ * overlay inset 0, holds the padding -> the right edge
128
+ * column flex column -> the bottom edge
129
+ * rail position: sticky -> stays reachable
130
+ * button
131
+ *
132
+ * The button itself carries none of the placement any more, so a test that
133
+ * reads `button.style.left` is reading the wrong node.
134
+ */
135
+ const controlBoxes = (container: HTMLElement) => {
136
+ const button = container.querySelector('button') as HTMLButtonElement;
137
+ const rail = button.parentElement as HTMLElement;
138
+ const column = rail.parentElement as HTMLElement;
139
+ const overlay = column.parentElement as HTMLElement;
140
+ return { button, rail, column, overlay };
141
+ };
142
+
44
143
  describe('VideoBlock', () => {
45
144
  /**
46
145
  * A designer's Corner Radius lands on the block's `[data-block-id]` box, and
@@ -814,18 +913,18 @@ describe('VideoBlock', () => {
814
913
  expect(video.pause).toHaveBeenCalledTimes(1);
815
914
  });
816
915
 
817
- it('stacks above a sibling content overlay (z-index 2) so its clicks are not swallowed', () => {
916
+ it('stacks above a sibling content overlay, so its clicks are not swallowed', () => {
818
917
  // A title/CTA content block placed over the video is a full-size
819
918
  // `position: absolute; z-index: 1` box (see pz-theme's group content
820
- // pattern) — its transparent area still captures pointer events. Without
821
- // an explicit, higher z-index the button was visually and functionally
822
- // underneath it, unclickable everywhere it overlapped.
919
+ // pattern) — its transparent area still captures pointer events. The
920
+ // z-index belongs on the control LAYER now, because that is the box the
921
+ // shopper's click has to land in before it can reach a button.
823
922
  const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
824
923
  controls: false,
825
924
  customPlayPause: true
826
925
  });
827
- const button = container.querySelector('button') as HTMLButtonElement;
828
- expect(Number(button.style.zIndex)).toBeGreaterThan(1);
926
+ const { overlay } = controlBoxes(container);
927
+ expect(Number(overlay.style.zIndex)).toBeGreaterThan(1);
829
928
  });
830
929
 
831
930
  it('matches the design: a 20x20 button, not an oversized icon', () => {
@@ -839,20 +938,40 @@ describe('VideoBlock', () => {
839
938
  });
840
939
 
841
940
  it('draws the pause icon as two 1x8 bars', () => {
941
+ // One path with the gap cut out of it, not two rects: at 8px the glyph
942
+ // has to read as a hairline, the way the outlined triangle beside it
943
+ // does, and a single path is how the reference storefront draws it.
944
+ // `playWhenVisible: false` is what puts this clip in the playing state
945
+ // on the first render: it keeps the block out of ambient mode, so the
946
+ // element still carries the autoplay attribute and the control is seeded
947
+ // to match it. An ambient clip is honestly stopped until the observer
948
+ // starts it, and draws the play glyph until then.
842
949
  const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
843
950
  controls: false,
844
951
  customPlayPause: true,
845
- autoplay: true // starts in the "playing" state, so the Pause icon renders
952
+ autoplay: true,
953
+ playWhenVisible: false
846
954
  });
847
- const rects = container.querySelectorAll('button svg rect');
848
- expect(rects).toHaveLength(2);
849
- rects.forEach(rect => {
850
- expect(rect.getAttribute('width')).toBe('1');
851
- expect(rect.getAttribute('height')).toBe('8');
955
+ const paths = container.querySelectorAll('button svg path');
956
+ expect(paths).toHaveLength(1);
957
+ expect(paths[0].getAttribute('d')).toBe('M1.5 8h-1V0h1zm6 0h-1V0h1z');
958
+ expect(container.querySelector('button svg')).toHaveAttribute(
959
+ 'viewBox',
960
+ '0 0 8 8'
961
+ );
962
+ });
963
+
964
+ it('draws the play icon as an outlined triangle, not a solid wedge', () => {
965
+ const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
966
+ controls: false,
967
+ customPlayPause: true
852
968
  });
969
+ const paths = container.querySelectorAll('button svg path');
970
+ expect(paths).toHaveLength(1);
971
+ expect(paths[0].getAttribute('d')).toBe('M7 4 1 8V0zM2 6.13 5.197 4 2 1.868z');
853
972
  });
854
973
 
855
- it('sits bottom-left, inset from the left edge, on desktop', () => {
974
+ it('sits in the bottom-right corner, 24px in from both edges, on desktop', () => {
856
975
  const { container } = render(
857
976
  <VideoBlock
858
977
  block={
@@ -867,14 +986,14 @@ describe('VideoBlock', () => {
867
986
  currentBreakpoint="desktop"
868
987
  />
869
988
  );
870
- const button = container.querySelector('button') as HTMLButtonElement;
871
- expect(button.style.bottom).toBe('0px');
872
- expect(button.style.left).toBe('24px');
873
- expect(button.style.top).toBe('');
874
- expect(button.style.right).toBe('');
989
+ const { overlay, column, rail } = controlBoxes(container);
990
+ expect(overlay.style.justifyContent).toBe('flex-end'); // the right edge
991
+ expect(column.style.justifyContent).toBe('flex-end'); // the bottom edge
992
+ expect(overlay.style.padding).toBe('24px');
993
+ expect(rail.style.bottom).toBe('24px');
875
994
  });
876
995
 
877
- it('moves to the top-right on mobile instead of just rescaling the desktop inset', () => {
996
+ it('keeps that same bottom-right corner on mobile, at a tighter 18px', () => {
878
997
  const { container } = render(
879
998
  <VideoBlock
880
999
  block={
@@ -889,12 +1008,508 @@ describe('VideoBlock', () => {
889
1008
  currentBreakpoint="mobile"
890
1009
  />
891
1010
  );
1011
+ const { overlay, column, rail } = controlBoxes(container);
1012
+ expect(overlay.style.justifyContent).toBe('flex-end');
1013
+ expect(column.style.justifyContent).toBe('flex-end');
1014
+ expect(overlay.style.padding).toBe('18px');
1015
+ expect(rail.style.bottom).toBe('18px');
1016
+ });
1017
+
1018
+ /*
1019
+ * The three names the panel writes rules against. A part the renderer does
1020
+ * not stamp is a whole group of controls that save and do nothing, so the
1021
+ * names are asserted here as well as by the cross-repo parity test — that
1022
+ * one is skipped wherever pz-dynamic-widget is not checked out, which is
1023
+ * every CI run.
1024
+ */
1025
+ describe('a glyph the brand supplied', () => {
1026
+ const MARK =
1027
+ 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=';
1028
+
1029
+ it('replaces the built-in one for the state it was uploaded against', () => {
1030
+ const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
1031
+ controls: false,
1032
+ customPlayPause: true,
1033
+ playIcon: MARK
1034
+ });
1035
+
1036
+ // The paused clip draws the authored mark, and the built-in triangle
1037
+ // is gone rather than sitting behind it.
1038
+ const masked = container.querySelector('button span') as HTMLElement;
1039
+ expect(masked).toBeInTheDocument();
1040
+ expect(masked.style.maskImage).toContain(MARK);
1041
+ expect(container.querySelector('button svg')).not.toBeInTheDocument();
1042
+ });
1043
+
1044
+ it('is painted by the panel, not by whatever colours the file carries', () => {
1045
+ // A mask filled with currentColor, because the button's colour lives in
1046
+ // generated part CSS the renderer cannot read — baking a colour into
1047
+ // the SVG the way the icon block does would ignore the panel entirely.
1048
+ const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
1049
+ controls: false,
1050
+ customPlayPause: true,
1051
+ playIcon: MARK
1052
+ });
1053
+
1054
+ const masked = container.querySelector('button span') as HTMLElement;
1055
+ expect(masked.style.backgroundColor).toBe('currentColor');
1056
+ });
1057
+
1058
+ it('leaves the other states on the marks that ship with the block', () => {
1059
+ // Only Play was uploaded, so Pause is still ours — the two states are
1060
+ // separate fields precisely so they can differ.
1061
+ const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
1062
+ controls: false,
1063
+ customPlayPause: true,
1064
+ playWhenVisible: false,
1065
+ autoplay: true,
1066
+ playIcon: MARK
1067
+ });
1068
+
1069
+ expect(container.querySelector('button svg')).toBeInTheDocument();
1070
+ expect(container.querySelector('button span')).not.toBeInTheDocument();
1071
+ });
1072
+
1073
+ it('keeps the built-in mark when the field was left empty', () => {
1074
+ const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
1075
+ controls: false,
1076
+ customPlayPause: true,
1077
+ playIcon: ''
1078
+ });
1079
+
1080
+ expect(container.querySelector('button svg')).toBeInTheDocument();
1081
+ });
1082
+ });
1083
+
1084
+ it('names the parts the panel styles: the overlay, the rail and the buttons', () => {
1085
+ const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
1086
+ controls: false,
1087
+ customPlayPause: true,
1088
+ soundToggle: true
1089
+ });
1090
+
1091
+ expect(container.querySelector('[data-part="controls"]')).toBeInTheDocument();
1092
+ expect(container.querySelector('[data-part="control-rail"]')).toBeInTheDocument();
1093
+ // Both buttons carry the SAME name: they are a matched pair and a brand
1094
+ // restyles them together.
1095
+ expect(container.querySelectorAll('[data-part="control-button"]')).toHaveLength(2);
1096
+ });
1097
+
1098
+ /*
1099
+ * The wrapper's handler is a REACT one — `WithDesignerFeatures` puts
1100
+ * `onClick` on the `[data-block-id]` div — so these two stand the block
1101
+ * inside a React `onClick` rather than adding a native listener to the RTL
1102
+ * container. React attaches its own listener at the root container, so a
1103
+ * native listener there fires whatever the synthetic event does, and the
1104
+ * test would be measuring the harness instead of the block.
1105
+ */
1106
+ const renderInsideClickableWrapper = (isDesigner: boolean) => {
1107
+ const onWrapperClick = jest.fn();
1108
+ const { container } = render(
1109
+ <div onClick={onWrapperClick}>
1110
+ <VideoBlock
1111
+ block={
1112
+ {
1113
+ value: 'https://cdn.example.com/clip.mp4',
1114
+ properties: { controls: false, customPlayPause: true }
1115
+ } as any
1116
+ }
1117
+ placeholderId="p1"
1118
+ sectionId="s1"
1119
+ isDesigner={isDesigner}
1120
+ />
1121
+ </div>
1122
+ );
1123
+ return { onWrapperClick, container };
1124
+ };
1125
+
1126
+ it('keeps the click off the block, so a video with an Action does not navigate on pause', () => {
1127
+ const { onWrapperClick, container } = renderInsideClickableWrapper(false);
1128
+ (container.querySelector('button') as HTMLButtonElement).click();
1129
+ expect(onWrapperClick).not.toHaveBeenCalled();
1130
+ });
1131
+
1132
+ it('lets the click through inside the designer, so the canvas can select the part', () => {
1133
+ // The canvas resolves which part was clicked from one bubble handler on
1134
+ // the block wrapper. Swallowing the event there selected nothing at all —
1135
+ // not the part, not even the block.
1136
+ const { onWrapperClick, container } = renderInsideClickableWrapper(true);
1137
+ (container.querySelector('button') as HTMLButtonElement).click();
1138
+ expect(onWrapperClick).toHaveBeenCalled();
1139
+ });
1140
+
1141
+ it('stays reachable on a portrait clip taller than the window', () => {
1142
+ // At 749px wide a 2:3 clip is 1123px tall — taller than most laptop
1143
+ // viewports — so a control parked at the bottom of the block is off
1144
+ // screen for the whole time the clip is being watched.
1145
+ const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
1146
+ controls: false,
1147
+ customPlayPause: true
1148
+ });
1149
+ const { overlay, rail } = controlBoxes(container);
1150
+ expect(rail.style.position).toBe('sticky');
1151
+ expect(rail.style.bottom).toBe(overlay.style.padding);
1152
+ });
1153
+
1154
+ it('lets a click reach whatever sits under the player, except on the button', () => {
1155
+ const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
1156
+ controls: false,
1157
+ customPlayPause: true
1158
+ });
1159
+ const { overlay, column, rail, button } = controlBoxes(container);
1160
+ expect(overlay.style.pointerEvents).toBe('none');
1161
+ expect(column.style.pointerEvents).toBe('none');
1162
+ expect(rail.style.pointerEvents).toBe('none');
1163
+ expect(button.style.pointerEvents).toBe('auto');
1164
+ });
1165
+ describe('the sound button beside it', () => {
1166
+ const renderControls = (properties: any) =>
1167
+ render(
1168
+ <VideoBlock
1169
+ block={
1170
+ {
1171
+ value: 'https://cdn.example.com/clip.mp4',
1172
+ properties: {
1173
+ controls: false,
1174
+ customPlayPause: true,
1175
+ muted: true,
1176
+ ...properties
1177
+ }
1178
+ } as any
1179
+ }
1180
+ placeholderId="p1"
1181
+ sectionId="s1"
1182
+ isDesigner={false}
1183
+ currentBreakpoint="desktop"
1184
+ />
1185
+ );
1186
+
1187
+ it('is absent unless the merchant asks for it, because an ambient clip is silent furniture', () => {
1188
+ const { container } = renderControls({});
1189
+ expect(container.querySelectorAll('button')).toHaveLength(1);
1190
+ });
1191
+
1192
+ it('cannot appear on its own, without the play/pause button it rides with', () => {
1193
+ const { container } = renderControls({
1194
+ customPlayPause: false,
1195
+ soundToggle: true
1196
+ });
1197
+ expect(container.querySelectorAll('button')).toHaveLength(0);
1198
+ });
1199
+
1200
+ it('appears beside play/pause when both are turned on', () => {
1201
+ const { container } = renderControls({ soundToggle: true });
1202
+ expect(container.querySelectorAll('button')).toHaveLength(2);
1203
+ });
1204
+
1205
+ it('is the left of the two, so play/pause keeps the corner it always had', () => {
1206
+ const { container } = renderControls({ soundToggle: true });
1207
+ const [first, second] = Array.from(container.querySelectorAll('button'));
1208
+ expect(first).toHaveAttribute('aria-label', 'Unmute video');
1209
+ expect(second).toHaveAttribute('aria-label', 'Play video');
1210
+ });
1211
+
1212
+ it('actually unmutes the clip when the shopper presses it, and says so', () => {
1213
+ const { container } = renderControls({ soundToggle: true });
1214
+ const video = container.querySelector('video') as HTMLVideoElement;
1215
+ const sound = container.querySelectorAll('button')[0] as HTMLButtonElement;
1216
+
1217
+ expect(video.muted).toBe(true);
1218
+ expect(sound).toHaveAttribute('aria-pressed', 'false');
1219
+
1220
+ act(() => {
1221
+ sound.click();
1222
+ });
1223
+
1224
+ expect(video.muted).toBe(false);
1225
+ expect(sound).toHaveAttribute('aria-label', 'Mute video');
1226
+ expect(sound).toHaveAttribute('aria-pressed', 'true');
1227
+ });
1228
+ });
1229
+
1230
+ /**
1231
+ * PLAYING ONLY WHILE THE CLIP IS ON SCREEN.
1232
+ *
1233
+ * An `autoPlay` attribute starts a clip wherever it sits on the page, so a
1234
+ * homepage with three of them decodes three films at once. Everything below
1235
+ * needs the recording observer: the one `jest/setup.ts` installs does
1236
+ * nothing, so without this harness the whole feature is invisible to a test.
1237
+ */
1238
+ describe('a clip that plays by itself', () => {
1239
+ const originalObserver = window.IntersectionObserver;
1240
+ const originalPlay = window.HTMLMediaElement.prototype.play;
1241
+ const originalPause = window.HTMLMediaElement.prototype.pause;
1242
+
1243
+ /**
1244
+ * The property set that makes a clip ambient.
1245
+ *
1246
+ * `controls: false` is not decoration. A player showing the browser's own
1247
+ * control bar is a deliberate watch and the viewport never touches it, so
1248
+ * leaving it out here would describe a block the observer is not supposed
1249
+ * to drive at all.
1250
+ */
1251
+ const ambient = {
1252
+ controls: false,
1253
+ autoplay: true,
1254
+ muted: true,
1255
+ loop: true
1256
+ };
1257
+
1258
+ let play: jest.Mock;
1259
+ let pause: jest.Mock;
1260
+
1261
+ beforeEach(() => {
1262
+ MockObserver.instances = [];
1263
+ (window as unknown as Record<string, unknown>).IntersectionObserver =
1264
+ MockObserver;
1265
+ play = jest.fn(() => Promise.resolve());
1266
+ pause = jest.fn();
1267
+ window.HTMLMediaElement.prototype.play =
1268
+ play as unknown as typeof originalPlay;
1269
+ window.HTMLMediaElement.prototype.pause = pause;
1270
+ });
1271
+
1272
+ afterEach(() => {
1273
+ (window as unknown as Record<string, unknown>).IntersectionObserver =
1274
+ originalObserver;
1275
+ window.HTMLMediaElement.prototype.play = originalPlay;
1276
+ window.HTMLMediaElement.prototype.pause = originalPause;
1277
+ });
1278
+
1279
+ const renderPlayer = (properties: any, isDesigner = false) =>
1280
+ render(
1281
+ <VideoBlock
1282
+ block={
1283
+ { value: 'https://cdn.example.com/clip.mp4', properties } as any
1284
+ }
1285
+ placeholderId="p1"
1286
+ sectionId="s1"
1287
+ isDesigner={isDesigner}
1288
+ currentBreakpoint="desktop"
1289
+ />
1290
+ );
1291
+
1292
+ it('starts as soon as a tenth of it has scrolled into view', () => {
1293
+ const { container } = renderPlayer({ ...ambient });
1294
+ const video = container.querySelector('video') as HTMLVideoElement;
1295
+
1296
+ expect(MockObserver.instances).toHaveLength(1);
1297
+ expect(MockObserver.instances[0].options?.threshold).toBe(0.1);
1298
+ expect(MockObserver.instances[0].elements).toEqual([video]);
1299
+
1300
+ MockObserver.instances[0].fire(true);
1301
+ expect(play).toHaveBeenCalledTimes(1);
1302
+ });
1303
+
1304
+ it('stops again when the shopper scrolls it off the screen', () => {
1305
+ renderPlayer({ ...ambient });
1306
+
1307
+ MockObserver.instances[0].fire(true);
1308
+ MockObserver.instances[0].fire(false);
1309
+
1310
+ expect(pause).toHaveBeenCalledTimes(1);
1311
+ });
1312
+
1313
+ it('picks up where it stopped instead of rewinding to the start', () => {
1314
+ const { container } = renderPlayer({ ...ambient });
1315
+ const video = container.querySelector('video') as HTMLVideoElement;
1316
+
1317
+ // jsdom's `currentTime` cannot be read back after a write, so the
1318
+ // property is swapped for one that records every seek attempt.
1319
+ const seeks: number[] = [];
1320
+ Object.defineProperty(video, 'currentTime', {
1321
+ configurable: true,
1322
+ get: () => 12,
1323
+ set: (value: number) => seeks.push(value)
1324
+ });
1325
+
1326
+ MockObserver.instances[0].fire(true);
1327
+ MockObserver.instances[0].fire(false);
1328
+ MockObserver.instances[0].fire(true);
1329
+
1330
+ expect(seeks).toEqual([]);
1331
+ expect(play).toHaveBeenCalledTimes(2);
1332
+ });
1333
+
1334
+ it('plays again after a hand pause once the shopper scrolls back to it', () => {
1335
+ // Measured on the reference storefront rather than overlooked: pause by
1336
+ // hand, scroll away, scroll back, and the clip is running again. The
1337
+ // viewport is authoritative and the button is an override that lasts
1338
+ // only while the clip is on screen.
1339
+ const { container } = renderPlayer({
1340
+ ...ambient,
1341
+ controls: false,
1342
+ customPlayPause: true
1343
+ });
1344
+ const video = container.querySelector('video') as HTMLVideoElement;
892
1345
  const button = container.querySelector('button') as HTMLButtonElement;
893
- expect(button.style.top).toBe('18px');
894
- expect(button.style.right).toBe('18px');
895
- expect(button.style.bottom).toBe('');
896
- expect(button.style.left).toBe('');
1346
+
1347
+ MockObserver.instances[0].fire(true);
1348
+ expect(play).toHaveBeenCalledTimes(1);
1349
+
1350
+ Object.defineProperty(video, 'paused', {
1351
+ value: false,
1352
+ configurable: true
1353
+ });
1354
+ act(() => {
1355
+ button.click();
1356
+ });
1357
+ expect(pause).toHaveBeenCalledTimes(1);
1358
+
1359
+ MockObserver.instances[0].fire(false);
1360
+ MockObserver.instances[0].fire(true);
1361
+
1362
+ expect(play).toHaveBeenCalledTimes(2);
1363
+ });
1364
+
1365
+ it('stops watching the viewport once the block leaves the page', () => {
1366
+ const { unmount } = renderPlayer({ ...ambient });
1367
+ expect(MockObserver.live).toHaveLength(1);
1368
+
1369
+ unmount();
1370
+
1371
+ expect(MockObserver.live).toHaveLength(0);
1372
+ });
1373
+
1374
+ it('leaves a clip the shopper has to press play on entirely alone', () => {
1375
+ renderPlayer({ muted: true, loop: true });
1376
+ expect(MockObserver.instances).toHaveLength(0);
1377
+ });
1378
+
1379
+ /*
1380
+ * The shape the editor ACTUALLY writes, which is the only shape almost
1381
+ * every published block is in: `getDefaultBlockProperties` stamps
1382
+ * `{ desktop: <default> }` for every property, so a merchant who touched
1383
+ * nothing has `controls: {desktop:true}` and `autoplay: {desktop:false}`.
1384
+ * `autoplay` is read raw and an object is truthy, so the clip LOOKS
1385
+ * ambient to that read — `controls` is the gate that saves it. Written
1386
+ * with plain booleans, this whole describe block would pass while every
1387
+ * published video on the storefront quietly became viewport-driven and
1388
+ * undid shoppers' manual pauses.
1389
+ */
1390
+ it('leaves alone the video every merchant already published, controls and all', () => {
1391
+ renderPlayer({
1392
+ controls: { desktop: true },
1393
+ autoplay: { desktop: false },
1394
+ muted: { desktop: true }
1395
+ });
1396
+ expect(MockObserver.instances).toHaveLength(0);
1397
+ });
1398
+
1399
+ it('drives the ambient clip the editor writes per breakpoint, object shape and all', () => {
1400
+ renderPlayer({
1401
+ controls: { desktop: false },
1402
+ customPlayPause: { desktop: true },
1403
+ autoplay: { desktop: true },
1404
+ muted: { desktop: true }
1405
+ });
1406
+ expect(MockObserver.instances).toHaveLength(1);
1407
+ });
1408
+
1409
+ it('plays from the moment the page loads when the merchant turned Play when visible off', () => {
1410
+ const { container } = renderPlayer({
1411
+ ...ambient,
1412
+ playWhenVisible: false
1413
+ });
1414
+ expect(MockObserver.instances).toHaveLength(0);
1415
+ expect(container.querySelector('video')).toHaveAttribute('autoplay');
1416
+ });
1417
+
1418
+ it('is not driven by the viewport inside the editor canvas', () => {
1419
+ renderPlayer({ ...ambient }, true);
1420
+ expect(MockObserver.instances).toHaveLength(0);
1421
+ });
1422
+
1423
+ describe('a shopper who asked for less motion', () => {
1424
+ it('gets the clip held still, with nothing watching to restart it', () => {
1425
+ withReducedMotion(() => {
1426
+ renderPlayer({ ...ambient });
1427
+ expect(MockObserver.instances).toHaveLength(0);
1428
+ expect(pause).toHaveBeenCalledTimes(1);
1429
+ });
1430
+ });
1431
+
1432
+ it('can still press play, because the file was not left unfetchable', () => {
1433
+ // The element ships `preload="none"` waiting for an observer that is
1434
+ // never armed here, so standing down has to release it too.
1435
+ withReducedMotion(() => {
1436
+ const { container } = renderPlayer({ ...ambient });
1437
+ expect(container.querySelector('video')).toHaveAttribute(
1438
+ 'preload',
1439
+ 'metadata'
1440
+ );
1441
+ });
1442
+ });
897
1443
  });
1444
+
1445
+ describe('how much of the file is fetched before anyone asks for it', () => {
1446
+ it('fetches nothing at all until an ambient clip reaches the viewport', () => {
1447
+ const { container } = renderPlayer({ ...ambient });
1448
+ expect(container.querySelector('video')).toHaveAttribute(
1449
+ 'preload',
1450
+ 'none'
1451
+ );
1452
+ });
1453
+
1454
+ it('ships that same nothing in the server HTML, so the player hydrates', () => {
1455
+ // Server and first client render have to agree: if the attribute
1456
+ // differed the element would be rebuilt rather than adopted, and the
1457
+ // shopper would pay for the swap.
1458
+ const html = renderToStaticMarkup(
1459
+ <VideoBlock
1460
+ block={
1461
+ {
1462
+ value: 'https://cdn.example.com/clip.mp4',
1463
+ properties: { ...ambient }
1464
+ } as any
1465
+ }
1466
+ placeholderId="p1"
1467
+ sectionId="s1"
1468
+ isDesigner={false}
1469
+ />
1470
+ );
1471
+ expect(html).toContain('preload="none"');
1472
+ });
1473
+
1474
+ it('fetches the amount the merchant authored once the clip has been seen', () => {
1475
+ const { container } = renderPlayer({ ...ambient, preload: 'auto' });
1476
+ MockObserver.instances[0].fire(true);
1477
+ expect(container.querySelector('video')).toHaveAttribute(
1478
+ 'preload',
1479
+ 'auto'
1480
+ );
1481
+ });
1482
+
1483
+ it('fetches that amount straight away when the clip never plays by itself', () => {
1484
+ const { container } = renderPlayer({ muted: true, preload: 'auto' });
1485
+ expect(container.querySelector('video')).toHaveAttribute(
1486
+ 'preload',
1487
+ 'auto'
1488
+ );
1489
+ });
1490
+
1491
+ it('asks for metadata only when the merchant authored nothing', () => {
1492
+ const { container } = renderPlayer({});
1493
+ expect(container.querySelector('video')).toHaveAttribute(
1494
+ 'preload',
1495
+ 'metadata'
1496
+ );
1497
+ });
1498
+
1499
+ it('loads normally in a browser that has no IntersectionObserver at all', () => {
1500
+ // Without this the clip would sit on `preload="none"` forever, waiting
1501
+ // for an observer the browser cannot build.
1502
+ delete (window as unknown as Record<string, unknown>)
1503
+ .IntersectionObserver;
1504
+ const { container } = renderPlayer({ ...ambient });
1505
+ expect(container.querySelector('video')).toHaveAttribute(
1506
+ 'preload',
1507
+ 'metadata'
1508
+ );
1509
+ });
1510
+ });
1511
+ });
1512
+
898
1513
  });
899
1514
  });
900
1515