utils.test.jsx (10696B)
1 import React, { useEffect } from "react"; 2 import { mount } from "enzyme"; 3 import { 4 useIntersectionObserver, 5 getActiveCardSize, 6 getActiveColumnLayout, 7 useConfetti, 8 selectWeatherPlacement, 9 } from "content-src/lib/utils.jsx"; 10 11 // Test component to use the useIntersectionObserver 12 function TestComponent({ callback, threshold }) { 13 const ref = useIntersectionObserver(callback, threshold); 14 return <div ref={el => ref.current.push(el)}></div>; 15 } 16 17 function TestConfettiComponent({ count, spread }) { 18 const [canvasRef, fireConfetti] = useConfetti(count, spread); 19 20 useEffect(() => { 21 // Trigger the animation once mounted 22 fireConfetti(); 23 }, [fireConfetti]); 24 25 return <canvas ref={canvasRef} width={100} height={100} />; 26 } 27 28 describe("useIntersectionObserver", () => { 29 let callback; 30 let threshold; 31 let sandbox; 32 let observerStub; 33 let wrapper; 34 35 beforeEach(() => { 36 sandbox = sinon.createSandbox(); 37 callback = sandbox.spy(); 38 threshold = 0.5; 39 observerStub = sandbox 40 .stub(window, "IntersectionObserver") 41 .callsFake(function (cb) { 42 this.observe = sandbox.spy(); 43 this.unobserve = sandbox.spy(); 44 this.disconnect = sandbox.spy(); 45 this.callback = cb; 46 }); 47 wrapper = mount( 48 <TestComponent callback={callback} threshold={threshold} /> 49 ); 50 }); 51 52 afterEach(() => { 53 sandbox.restore(); 54 wrapper.unmount(); 55 }); 56 57 it("should create an IntersectionObserver instance with the correct options", () => { 58 assert.calledWithNew(observerStub); 59 assert.calledWith(observerStub, sinon.match.any, { threshold }); 60 }); 61 62 it("should observe elements when mounted", () => { 63 const observerInstance = observerStub.getCall(0).returnValue; 64 assert.called(observerInstance.observe); 65 }); 66 67 it("should call callback and unobserve element when it intersects", () => { 68 wrapper = mount( 69 <TestComponent callback={callback} threshold={threshold} /> 70 ); 71 const observerInstance = observerStub.getCall(0).returnValue; 72 const observedElement = wrapper.find("div").getDOMNode(); 73 74 // Simulate an intersection 75 observerInstance.callback([ 76 { isIntersecting: true, target: observedElement }, 77 ]); 78 79 assert.calledOnce(callback); 80 assert.calledWith(callback, observedElement); 81 assert.calledOnce(observerInstance.unobserve); 82 assert.calledWith(observerInstance.unobserve, observedElement); 83 }); 84 85 it("should not call callback if element is not intersecting", () => { 86 wrapper = mount( 87 <TestComponent callback={callback} threshold={threshold} /> 88 ); 89 const observerInstance = observerStub.getCall(0).returnValue; 90 const observedElement = wrapper.find("div").getDOMNode(); 91 92 // Simulate a non-intersecting entry 93 observerInstance.callback([ 94 { isIntersecting: false, target: observedElement }, 95 ]); 96 97 assert.notCalled(callback); 98 assert.notCalled(observerInstance.unobserve); 99 }); 100 }); 101 102 describe("getActiveCardSize", () => { 103 it("returns 'large-card' for col-4-large and screen width 1920 and sections enabled", () => { 104 const result = getActiveCardSize( 105 1920, 106 "col-4-large col-3-medium col-2-small col-1-small", 107 true 108 ); 109 assert.equal(result, "large-card"); 110 }); 111 112 it("returns 'medium-card' for col-3-medium and screen width 1200 and sections enabled", () => { 113 const result = getActiveCardSize( 114 1200, 115 "col-4-large col-3-medium col-2-small col-1-small", 116 true 117 ); 118 assert.equal(result, "medium-card"); 119 }); 120 121 it("returns 'small-card' for col-2-small and screen width 800 and sections enabled", () => { 122 const result = getActiveCardSize( 123 800, 124 "col-4-large col-3-medium col-2-small col-1-medium", 125 true 126 ); 127 assert.equal(result, "small-card"); 128 }); 129 130 it("returns 'medium-card' for col-1-medium at 500px", () => { 131 const result = getActiveCardSize( 132 500, 133 "col-1-medium col-1-position-0", 134 true 135 ); 136 assert.equal(result, "medium-card"); 137 }); 138 139 it("returns 'medium-card' for col-1-small at 500px (edge case)", () => { 140 const result = getActiveCardSize(500, "col-1-small col-1-position-0", true); 141 assert.equal(result, "medium-card"); 142 }); 143 144 it("returns null when no matching card type is found (edge case)", () => { 145 const result = getActiveCardSize( 146 1200, 147 "col-4-position-0 col-3-position-0", 148 true 149 ); 150 assert.isNull(result); 151 }); 152 153 it("returns 'medium-card' when required arguments are missing and sections are disabled", () => { 154 const result = getActiveCardSize(null, null, false); 155 assert.equal(result, "medium-card"); 156 }); 157 158 it("returns null when required arguments are missing and sections are enabled", () => { 159 const result = getActiveCardSize(null, null, true); 160 assert.isNull(result); 161 }); 162 163 it("returns 'spoc' when flightId has value", () => { 164 const result = getActiveCardSize(null, null, false, 123); 165 assert.equal(result, "spoc"); 166 }); 167 }); 168 169 describe("getActiveColumnLayout", () => { 170 it("returns 'col-4' for screen width 1920", () => { 171 const result = getActiveColumnLayout(1920); 172 assert.equal(result, "col-4"); 173 }); 174 175 it("returns 'col-3' for screen width 1200", () => { 176 const result = getActiveColumnLayout(1200); 177 assert.equal(result, "col-3"); 178 }); 179 180 it("returns 'col-2' for screen width 800", () => { 181 const result = getActiveColumnLayout(800); 182 assert.equal(result, "col-2"); 183 }); 184 185 it("returns 'col-1' for screen width 500", () => { 186 const result = getActiveColumnLayout(500); 187 assert.equal(result, "col-1"); 188 }); 189 }); 190 191 describe("useConfetti hook", () => { 192 let sandbox; 193 let rafStub; 194 // eslint-disable-next-line no-unused-vars 195 let cafStub; 196 let getContextStub; 197 let fakeContext; 198 199 beforeEach(() => { 200 sandbox = sinon.createSandbox(); 201 202 // Create a fake 2D context 203 fakeContext = { 204 clearRect: sandbox.spy(), 205 setTransform: sandbox.spy(), 206 rotate: sandbox.spy(), 207 scale: sandbox.spy(), 208 fillRect: sandbox.spy(), 209 globalAlpha: 1, 210 }; 211 212 // Stub getContext on all canvas elements 213 getContextStub = sandbox 214 .stub(HTMLCanvasElement.prototype, "getContext") 215 .withArgs("2d") 216 .returns(fakeContext); 217 218 sandbox 219 .stub(window, "matchMedia") 220 .withArgs("(prefers-reduced-motion: reduce)") 221 .returns({ matches: false }); 222 223 // stub so that it only runs for one frame 224 rafStub = sandbox.stub(window, "requestAnimationFrame").returns(24); 225 cafStub = sandbox.stub(window, "cancelAnimationFrame"); 226 }); 227 228 afterEach(() => { 229 sandbox.restore(); 230 }); 231 232 it("should initialize and animate confetti when fireConfetti is called", () => { 233 // Mount the component, which calls fireConfetti in useEffect 234 mount(<TestConfettiComponent count={5} />); 235 assert.calledWith(getContextStub, "2d"); 236 assert.ok(fakeContext.clearRect.calledOnce); 237 assert.equal(fakeContext.fillRect.callCount, 5); 238 assert.ok(rafStub.calledOnce); 239 }); 240 it("does nothing when prefers-reduced-motion is enabled", () => { 241 // simulate prefers reduced motion 242 window.matchMedia 243 .withArgs("(prefers-reduced-motion: reduce)") 244 .returns({ matches: true }); 245 246 mount(<TestConfettiComponent count={5} />); 247 248 // Confrim the confetti hasnt been drawn 249 assert.ok(fakeContext.clearRect.notCalled); 250 assert.ok(fakeContext.fillRect.notCalled); 251 assert.ok(rafStub.notCalled); 252 }); 253 }); 254 255 describe("selectWeatherPlacement", () => { 256 // literal URL used inside the selector 257 const FEED_URL = 258 "https://merino.services.mozilla.com/api/v1/curated-recommendations"; 259 260 function mockState({ 261 placement, 262 pocketEnabled = true, 263 systemEnabled = true, 264 dailyBriefEnabled = true, 265 sectionId = "daily_brief", 266 blocked = false, 267 sections = [ 268 { sectionKey: "daily_brief", receivedRank: 0 }, 269 { sectionKey: "other", receivedRank: 1 }, 270 ], 271 } = {}) { 272 return { 273 Prefs: { 274 values: { 275 // intent pref 276 "weather.placement": placement, 277 // story feed prefs used by selector in this file 278 "feeds.section.topstories": pocketEnabled, 279 "feeds.system.topstories": systemEnabled, 280 // daily brief prefs; selector uses trainhopConfig first, falls back to these 281 "discoverystream.dailyBrief.enabled": dailyBriefEnabled, 282 "discoverystream.dailyBrief.sectionId": sectionId, 283 // include trainhopConfig for parity with production (optional) 284 trainhopConfig: { 285 dailyBriefing: { 286 enabled: dailyBriefEnabled, 287 sectionId, 288 }, 289 }, 290 }, 291 }, 292 DiscoveryStream: { 293 sectionPersonalization: { 294 [sectionId]: { isBlocked: blocked }, 295 }, 296 feeds: { 297 data: { 298 [FEED_URL]: { 299 data: { sections }, 300 }, 301 }, 302 }, 303 }, 304 }; 305 } 306 307 it("returns 'header' when placement pref is missing or 'header'", () => { 308 const invalidPlacement = mockState({ placement: undefined }); 309 310 console.log( 311 "TESTSTATE: ", 312 invalidPlacement.Prefs.values["weather.placement"] 313 ); 314 const headerPLacement = mockState({ placement: "header" }); 315 assert.equal(selectWeatherPlacement(invalidPlacement), "header"); 316 assert.equal(selectWeatherPlacement(headerPLacement), "header"); 317 }); 318 319 it("returns 'section' when placement is 'section' and daily brief is enabled, unblocked, and at top", () => { 320 const state = mockState({ placement: "section" }); 321 assert.equal(selectWeatherPlacement(state), "section"); 322 }); 323 324 it("returns 'header' when DB section is not at the top (receivedRank !== 0 || index !== 0)", () => { 325 const state = mockState({ 326 placement: "section", 327 sections: [ 328 { sectionKey: "other", receivedRank: 0 }, 329 { sectionKey: "daily_brief", receivedRank: 1 }, 330 ], 331 }); 332 assert.equal(selectWeatherPlacement(state), "header"); 333 }); 334 335 it("returns 'header' when DB section is blocked", () => { 336 const state = mockState({ blocked: true, placement: "section" }); 337 assert.equal(selectWeatherPlacement(state), "header"); 338 }); 339 340 it("returns 'header' when Pocket/topstories is disabled", () => { 341 const state = mockState({ 342 placement: "section", 343 pocketEnabled: false, 344 }); 345 assert.equal(selectWeatherPlacement(state), "header"); 346 }); 347 348 it("returns 'header' when sections have not loaded yet", () => { 349 const state = mockState({ 350 placement: "section", 351 sections: [], // simulate no feed yet 352 }); 353 assert.equal(selectWeatherPlacement(state), "header"); 354 }); 355 });