ImpressionStats.test.jsx (11750B)
1 import { 2 ImpressionStats, 3 INTERSECTION_RATIO, 4 } from "content-src/components/DiscoveryStreamImpressionStats/ImpressionStats"; 5 import { actionTypes as at } from "common/Actions.mjs"; 6 import React from "react"; 7 import { shallow } from "enzyme"; 8 9 describe("<ImpressionStats>", () => { 10 const SOURCE = "TEST_SOURCE"; 11 const FullIntersectEntries = [ 12 { isIntersecting: true, intersectionRatio: INTERSECTION_RATIO }, 13 ]; 14 const ZeroIntersectEntries = [ 15 { isIntersecting: false, intersectionRatio: 0 }, 16 ]; 17 const PartialIntersectEntries = [ 18 { isIntersecting: true, intersectionRatio: INTERSECTION_RATIO / 2 }, 19 ]; 20 21 // Build IntersectionObserver class with the arg `entries` for the intersect callback. 22 function buildIntersectionObserver(entries) { 23 return class { 24 constructor(callback) { 25 this.callback = callback; 26 } 27 28 observe() { 29 this.callback(entries); 30 } 31 32 unobserve() {} 33 }; 34 } 35 36 const TEST_FETCH_TIMESTAMP = Date.now(); 37 const TEST_FIRST_VISIBLE_TIMESTAMP = Date.now(); 38 const DEFAULT_PROPS = { 39 rows: [ 40 { id: 1, pos: 0, fetchTimestamp: TEST_FETCH_TIMESTAMP }, 41 { id: 2, pos: 1, fetchTimestamp: TEST_FETCH_TIMESTAMP }, 42 { id: 3, pos: 2, fetchTimestamp: TEST_FETCH_TIMESTAMP }, 43 ], 44 firstVisibleTimestamp: TEST_FIRST_VISIBLE_TIMESTAMP, 45 source: SOURCE, 46 IntersectionObserver: buildIntersectionObserver(FullIntersectEntries), 47 document: { 48 visibilityState: "visible", 49 addEventListener: sinon.stub(), 50 removeEventListener: sinon.stub(), 51 }, 52 }; 53 54 const InnerEl = () => <div>Inner Element</div>; 55 56 function renderImpressionStats(props = {}) { 57 return shallow( 58 <ImpressionStats {...DEFAULT_PROPS} {...props}> 59 <InnerEl /> 60 </ImpressionStats> 61 ); 62 } 63 64 it("should render props.children", () => { 65 const wrapper = renderImpressionStats(); 66 assert.ok(wrapper.contains(<InnerEl />)); 67 }); 68 it("should not send loaded content nor impression when the page is not visible", () => { 69 const dispatch = sinon.spy(); 70 const props = { 71 dispatch, 72 document: { 73 visibilityState: "hidden", 74 addEventListener: sinon.spy(), 75 removeEventListener: sinon.spy(), 76 }, 77 }; 78 renderImpressionStats(props); 79 80 assert.notCalled(dispatch); 81 }); 82 it("should only send loaded content but not impression when the wrapped item is not visbible", () => { 83 const dispatch = sinon.spy(); 84 const props = { 85 dispatch, 86 IntersectionObserver: buildIntersectionObserver(ZeroIntersectEntries), 87 }; 88 renderImpressionStats(props); 89 90 // This one is for loaded content. 91 assert.calledOnce(dispatch); 92 const [action] = dispatch.firstCall.args; 93 assert.equal(action.type, at.DISCOVERY_STREAM_LOADED_CONTENT); 94 assert.equal(action.data.source, SOURCE); 95 assert.deepEqual(action.data.tiles, [ 96 { id: 1, pos: 0 }, 97 { id: 2, pos: 1 }, 98 { id: 3, pos: 2 }, 99 ]); 100 }); 101 it("should not send impression when the wrapped item is visbible but below the ratio", () => { 102 const dispatch = sinon.spy(); 103 const props = { 104 dispatch, 105 IntersectionObserver: buildIntersectionObserver(PartialIntersectEntries), 106 }; 107 renderImpressionStats(props); 108 109 // This one is for loaded content. 110 assert.calledOnce(dispatch); 111 }); 112 it("should send a loaded content and an impression when the page is visible and the wrapped item meets the visibility ratio", () => { 113 const dispatch = sinon.spy(); 114 const props = { 115 dispatch, 116 IntersectionObserver: buildIntersectionObserver(FullIntersectEntries), 117 }; 118 renderImpressionStats(props); 119 120 assert.calledTwice(dispatch); 121 122 let [action] = dispatch.firstCall.args; 123 assert.equal(action.type, at.DISCOVERY_STREAM_LOADED_CONTENT); 124 assert.equal(action.data.source, SOURCE); 125 assert.deepEqual(action.data.tiles, [ 126 { id: 1, pos: 0 }, 127 { id: 2, pos: 1 }, 128 { id: 3, pos: 2 }, 129 ]); 130 131 [action] = dispatch.secondCall.args; 132 assert.equal(action.type, at.DISCOVERY_STREAM_IMPRESSION_STATS); 133 assert.equal(action.data.source, SOURCE); 134 assert.equal( 135 action.data.firstVisibleTimestamp, 136 TEST_FIRST_VISIBLE_TIMESTAMP 137 ); 138 assert.deepEqual(action.data.tiles, [ 139 { 140 id: 1, 141 pos: 0, 142 type: "organic", 143 recommendation_id: undefined, 144 fetchTimestamp: TEST_FETCH_TIMESTAMP, 145 scheduled_corpus_item_id: undefined, 146 corpus_item_id: undefined, 147 recommended_at: undefined, 148 received_rank: undefined, 149 topic: undefined, 150 features: undefined, 151 attribution: undefined, 152 format: "medium-card", 153 }, 154 { 155 id: 2, 156 pos: 1, 157 type: "organic", 158 recommendation_id: undefined, 159 fetchTimestamp: TEST_FETCH_TIMESTAMP, 160 scheduled_corpus_item_id: undefined, 161 corpus_item_id: undefined, 162 recommended_at: undefined, 163 received_rank: undefined, 164 topic: undefined, 165 features: undefined, 166 attribution: undefined, 167 format: "medium-card", 168 }, 169 { 170 id: 3, 171 pos: 2, 172 type: "organic", 173 recommendation_id: undefined, 174 fetchTimestamp: TEST_FETCH_TIMESTAMP, 175 scheduled_corpus_item_id: undefined, 176 corpus_item_id: undefined, 177 recommended_at: undefined, 178 received_rank: undefined, 179 topic: undefined, 180 features: undefined, 181 attribution: undefined, 182 format: "medium-card", 183 }, 184 ]); 185 assert.equal( 186 action.data.firstVisibleTimestamp, 187 TEST_FIRST_VISIBLE_TIMESTAMP 188 ); 189 }); 190 it("should send a DISCOVERY_STREAM_SPOC_IMPRESSION when the wrapped item has a flightId", () => { 191 const dispatch = sinon.spy(); 192 const flightId = "a_flight_id"; 193 const props = { 194 dispatch, 195 flightId, 196 rows: [{ id: 1, pos: 1, advertiser: "test advertiser" }], 197 source: "TOP_SITES", 198 IntersectionObserver: buildIntersectionObserver(FullIntersectEntries), 199 }; 200 renderImpressionStats(props); 201 202 // Loaded content + DISCOVERY_STREAM_SPOC_IMPRESSION + TOP_SITES_SPONSORED_IMPRESSION_STATS + impression 203 assert.callCount(dispatch, 4); 204 205 const [action] = dispatch.secondCall.args; 206 assert.equal(action.type, at.DISCOVERY_STREAM_SPOC_IMPRESSION); 207 assert.deepEqual(action.data, { flightId }); 208 }); 209 it("should send a TOP_SITES_SPONSORED_IMPRESSION_STATS when the wrapped item has a flightId", () => { 210 const dispatch = sinon.spy(); 211 const flightId = "a_flight_id"; 212 const props = { 213 dispatch, 214 flightId, 215 rows: [{ id: 1, pos: 1, advertiser: "test advertiser" }], 216 source: "TOP_SITES", 217 IntersectionObserver: buildIntersectionObserver(FullIntersectEntries), 218 }; 219 renderImpressionStats(props); 220 221 // Loaded content + DISCOVERY_STREAM_SPOC_IMPRESSION + TOP_SITES_SPONSORED_IMPRESSION_STATS + impression 222 assert.callCount(dispatch, 4); 223 224 const [action] = dispatch.getCall(2).args; 225 assert.equal(action.type, at.TOP_SITES_SPONSORED_IMPRESSION_STATS); 226 assert.deepEqual(action.data, { 227 type: "impression", 228 tile_id: 1, 229 source: "newtab", 230 advertiser: "test advertiser", 231 position: 1, 232 attribution: undefined, 233 }); 234 }); 235 it("should send an impression when the wrapped item transiting from invisible to visible", () => { 236 const dispatch = sinon.spy(); 237 const props = { 238 dispatch, 239 IntersectionObserver: buildIntersectionObserver(ZeroIntersectEntries), 240 }; 241 const wrapper = renderImpressionStats(props); 242 243 // For the loaded content 244 assert.calledOnce(dispatch); 245 246 let [action] = dispatch.firstCall.args; 247 assert.equal(action.type, at.DISCOVERY_STREAM_LOADED_CONTENT); 248 assert.equal(action.data.source, SOURCE); 249 assert.deepEqual(action.data.tiles, [ 250 { id: 1, pos: 0 }, 251 { id: 2, pos: 1 }, 252 { id: 3, pos: 2 }, 253 ]); 254 255 dispatch.resetHistory(); 256 wrapper.instance().impressionObserver.callback(FullIntersectEntries); 257 258 // For the impression 259 assert.calledOnce(dispatch); 260 261 [action] = dispatch.firstCall.args; 262 assert.equal(action.type, at.DISCOVERY_STREAM_IMPRESSION_STATS); 263 assert.deepEqual(action.data.tiles, [ 264 { 265 id: 1, 266 pos: 0, 267 type: "organic", 268 recommendation_id: undefined, 269 scheduled_corpus_item_id: undefined, 270 corpus_item_id: undefined, 271 recommended_at: undefined, 272 received_rank: undefined, 273 fetchTimestamp: TEST_FETCH_TIMESTAMP, 274 topic: undefined, 275 features: undefined, 276 attribution: undefined, 277 format: "medium-card", 278 }, 279 { 280 id: 2, 281 pos: 1, 282 type: "organic", 283 recommendation_id: undefined, 284 scheduled_corpus_item_id: undefined, 285 corpus_item_id: undefined, 286 recommended_at: undefined, 287 received_rank: undefined, 288 fetchTimestamp: TEST_FETCH_TIMESTAMP, 289 topic: undefined, 290 features: undefined, 291 attribution: undefined, 292 format: "medium-card", 293 }, 294 { 295 id: 3, 296 pos: 2, 297 type: "organic", 298 recommendation_id: undefined, 299 scheduled_corpus_item_id: undefined, 300 corpus_item_id: undefined, 301 recommended_at: undefined, 302 received_rank: undefined, 303 fetchTimestamp: TEST_FETCH_TIMESTAMP, 304 topic: undefined, 305 features: undefined, 306 attribution: undefined, 307 format: "medium-card", 308 }, 309 ]); 310 assert.equal( 311 action.data.firstVisibleTimestamp, 312 TEST_FIRST_VISIBLE_TIMESTAMP 313 ); 314 }); 315 it("should remove visibility change listener when the wrapper is removed", () => { 316 const props = { 317 dispatch: sinon.spy(), 318 document: { 319 visibilityState: "hidden", 320 addEventListener: sinon.spy(), 321 removeEventListener: sinon.spy(), 322 }, 323 IntersectionObserver, 324 }; 325 326 const wrapper = renderImpressionStats(props); 327 assert.calledWith(props.document.addEventListener, "visibilitychange"); 328 const [, listener] = props.document.addEventListener.firstCall.args; 329 330 wrapper.unmount(); 331 assert.calledWith( 332 props.document.removeEventListener, 333 "visibilitychange", 334 listener 335 ); 336 }); 337 it("should unobserve the intersection observer when the wrapper is removed", () => { 338 // eslint-disable-next-line no-shadow 339 const IntersectionObserver = 340 buildIntersectionObserver(ZeroIntersectEntries); 341 const spy = sinon.spy(IntersectionObserver.prototype, "unobserve"); 342 const props = { dispatch: sinon.spy(), IntersectionObserver }; 343 344 const wrapper = renderImpressionStats(props); 345 wrapper.unmount(); 346 347 assert.calledOnce(spy); 348 }); 349 it("should only send the latest impression on a visibility change", () => { 350 const listeners = new Set(); 351 const props = { 352 dispatch: sinon.spy(), 353 document: { 354 visibilityState: "hidden", 355 addEventListener: (ev, cb) => listeners.add(cb), 356 removeEventListener: (ev, cb) => listeners.delete(cb), 357 }, 358 }; 359 360 const wrapper = renderImpressionStats(props); 361 362 // Update twice 363 wrapper.setProps({ ...props, ...{ rows: [{ id: 123, pos: 4 }] } }); 364 wrapper.setProps({ ...props, ...{ rows: [{ id: 2432, pos: 5 }] } }); 365 366 assert.notCalled(props.dispatch); 367 368 // Simulate listeners getting called 369 props.document.visibilityState = "visible"; 370 listeners.forEach(l => l()); 371 372 // Make sure we only sent the latest event 373 assert.calledTwice(props.dispatch); 374 const [action] = props.dispatch.firstCall.args; 375 assert.deepEqual(action.data.tiles, [{ id: 2432, pos: 5 }]); 376 }); 377 });