FocusTimer.test.jsx (20126B)
1 import React from "react"; 2 import { combineReducers, createStore } from "redux"; 3 import { Provider } from "react-redux"; 4 import { mount } from "enzyme"; 5 import { INITIAL_STATE, reducers } from "common/Reducers.sys.mjs"; 6 import { actionTypes as at } from "common/Actions.mjs"; 7 import { 8 FocusTimer, 9 isNumericValue, 10 isAtMaxLength, 11 } from "content-src/components/Widgets/FocusTimer/FocusTimer"; 12 13 const PREF_WIDGETS_SYSTEM_NOTIFICATIONS_ENABLED = 14 "widgets.focusTimer.showSystemNotifications"; 15 16 const mockState = { 17 ...INITIAL_STATE, 18 Prefs: { 19 ...INITIAL_STATE.Prefs, 20 values: { 21 ...INITIAL_STATE.Prefs.values, 22 [PREF_WIDGETS_SYSTEM_NOTIFICATIONS_ENABLED]: true, 23 }, 24 }, 25 TimerWidget: { 26 timerType: "focus", 27 focus: { 28 duration: 1500, 29 initialDuration: 1500, 30 startTime: null, 31 isRunning: null, 32 }, 33 break: { 34 duration: 300, 35 initialDuration: 300, 36 startTime: null, 37 isRunning: null, 38 }, 39 }, 40 }; 41 42 function WrapWithProvider({ children, state = INITIAL_STATE }) { 43 let store = createStore(combineReducers(reducers), state); 44 return <Provider store={store}>{children}</Provider>; 45 } 46 47 describe("<FocusTimer>", () => { 48 let wrapper; 49 let sandbox; 50 let dispatch; 51 let clock; // for use with the sinon fake timers api 52 let handleUserInteraction; 53 54 beforeEach(() => { 55 sandbox = sinon.createSandbox(); 56 dispatch = sandbox.stub(); 57 clock = sandbox.useFakeTimers(); 58 handleUserInteraction = sandbox.stub(); 59 60 wrapper = mount( 61 <WrapWithProvider state={mockState}> 62 <FocusTimer 63 dispatch={dispatch} 64 handleUserInteraction={handleUserInteraction} 65 /> 66 </WrapWithProvider> 67 ); 68 }); 69 70 afterEach(() => { 71 // restore real timers after each test to avoid leaking sinon's fakeTimers() 72 sandbox.restore(); 73 wrapper?.unmount(); 74 }); 75 76 it("should render timer widget", () => { 77 assert.ok(wrapper.exists()); 78 assert.ok(wrapper.find(".focus-timer").exists()); 79 }); 80 81 it("should show default minutes for Focus timer (25 minutes)", () => { 82 const minutes = wrapper.find(".timer-set-minutes").text(); 83 const seconds = wrapper.find(".timer-set-seconds").text(); 84 assert.equal(minutes, "25"); 85 assert.equal(seconds, "00"); 86 }); 87 88 it("should show default minutes for Break timer (5 minutes)", () => { 89 const breakState = { 90 ...mockState, 91 TimerWidget: { 92 ...mockState.TimerWidget, 93 timerType: "break", // setting timer type to break 94 }, 95 }; 96 97 wrapper = mount( 98 <WrapWithProvider state={breakState}> 99 <FocusTimer 100 dispatch={dispatch} 101 handleUserInteraction={handleUserInteraction} 102 /> 103 </WrapWithProvider> 104 ); 105 106 const minutes = wrapper.find(".timer-set-minutes").text(); 107 const seconds = wrapper.find(".timer-set-seconds").text(); 108 109 assert.equal(minutes, "05"); 110 assert.equal(seconds, "00"); 111 }); 112 113 it("should start timer and show progress bar when pressing play", () => { 114 wrapper 115 .find("moz-button[data-l10n-id='newtab-widget-timer-label-play']") 116 .props() 117 .onClick(); 118 wrapper.update(); 119 assert.ok(wrapper.find(".progress-circle-wrapper").exists()); 120 assert.equal(dispatch.getCall(0).args[0].type, at.WIDGETS_TIMER_PLAY); 121 }); 122 123 it("should pause the timer when pressing pause", () => { 124 const now = Math.floor(Date.now() / 1000); 125 const runningState = { 126 ...mockState, 127 TimerWidget: { 128 ...mockState.TimerWidget, 129 focus: { 130 ...mockState.TimerWidget.focus, 131 isRunning: true, 132 startTime: now, 133 }, 134 }, 135 }; 136 137 wrapper = mount( 138 <WrapWithProvider state={runningState}> 139 <FocusTimer 140 dispatch={dispatch} 141 handleUserInteraction={handleUserInteraction} 142 /> 143 </WrapWithProvider> 144 ); 145 146 const pauseBtn = wrapper.find( 147 "moz-button[data-l10n-id='newtab-widget-timer-label-pause']" 148 ); 149 assert.ok(pauseBtn.exists(), "Pause button should be rendered"); 150 pauseBtn.props().onClick(); 151 assert.equal(dispatch.getCall(0).args[0].type, at.WIDGETS_TIMER_PAUSE); 152 }); 153 154 it("should reset timer should be hidden when timer is not running", () => { 155 const resetBtn = wrapper.find( 156 "moz-button[data-l10n-id='newtab-widget-timer-reset']" 157 ); 158 assert.ok(!resetBtn.exists(), "Reset buttons should not be rendered"); 159 }); 160 161 it("should reset timer when pressing reset", () => { 162 const now = Math.floor(Date.now() / 1000); 163 const runningState = { 164 ...mockState, 165 TimerWidget: { 166 ...mockState.TimerWidget, 167 focus: { 168 ...mockState.TimerWidget.focus, 169 isRunning: true, 170 startTime: now, 171 }, 172 }, 173 }; 174 175 wrapper = mount( 176 <WrapWithProvider state={runningState}> 177 <FocusTimer 178 dispatch={dispatch} 179 handleUserInteraction={handleUserInteraction} 180 /> 181 </WrapWithProvider> 182 ); 183 184 const resetBtn = wrapper.find( 185 "moz-button[data-l10n-id='newtab-widget-timer-reset']" 186 ); 187 188 assert.ok(resetBtn.exists(), "Reset buttons should be rendered"); 189 resetBtn.props().onClick(); 190 assert.equal(dispatch.getCall(0).args[0].type, at.WIDGETS_TIMER_RESET); 191 192 const initialUserDuration = 12 * 60; 193 194 const resetState = { 195 ...mockState, 196 TimerWidget: { 197 ...mockState.TimerWidget, 198 focus: { 199 duration: initialUserDuration, 200 initialDuration: initialUserDuration, 201 startTime: null, 202 isRunning: false, 203 }, 204 }, 205 }; 206 207 wrapper = mount( 208 <WrapWithProvider state={resetState}> 209 <FocusTimer 210 dispatch={dispatch} 211 handleUserInteraction={handleUserInteraction} 212 /> 213 </WrapWithProvider> 214 ); 215 216 assert.equal(wrapper.find(".progress-circle-wrapper.visible").length, 0); 217 const minutes = wrapper.find(".timer-set-minutes").text(); 218 const seconds = wrapper.find(".timer-set-seconds").text(); 219 assert.equal(minutes, "12"); 220 assert.equal(seconds, "00"); 221 }); 222 223 it("should dispatch pause and set type and when clicking the break timer", () => { 224 const breakBtn = wrapper.find( 225 "moz-button[data-l10n-id='newtab-widget-timer-mode-break']" 226 ); 227 assert.ok(breakBtn.exists(), "break button should be rendered"); 228 breakBtn.props().onClick(); 229 230 const types = dispatch 231 .getCalls() 232 .map(call => call.args[0].type) 233 .filter(Boolean); 234 235 assert.ok(types.includes(at.WIDGETS_TIMER_PAUSE)); 236 assert.ok(types.includes(at.WIDGETS_TIMER_SET_TYPE)); 237 238 const findTypeToggled = dispatch 239 .getCalls() 240 .map(call => call.args[0]) 241 .find(action => action.type === at.WIDGETS_TIMER_SET_TYPE); 242 243 assert.equal( 244 findTypeToggled.data.timerType, 245 "break", 246 "timer should switch to break mode" 247 ); 248 }); 249 250 it("should dispatch set type when clicking the focus timer", () => { 251 const focusBtn = wrapper.find( 252 "moz-button[data-l10n-id='newtab-widget-timer-mode-focus']" 253 ); 254 assert.ok(focusBtn.exists(), "focus button should be rendered"); 255 focusBtn.props().onClick(); 256 257 const types = dispatch 258 .getCalls() 259 .map(call => call.args[0].type) 260 .filter(Boolean); 261 262 assert.ok(types.includes(at.WIDGETS_TIMER_PAUSE)); 263 assert.ok(types.includes(at.WIDGETS_TIMER_SET_TYPE)); 264 265 const findTypeToggled = dispatch 266 .getCalls() 267 .map(call => call.args[0]) 268 .find(action => action.type === at.WIDGETS_TIMER_SET_TYPE); 269 270 assert.equal( 271 findTypeToggled.data.timerType, 272 "focus", 273 "focus should switch to break mode" 274 ); 275 }); 276 277 it("should toggle from focus to break timer automatically on end", () => { 278 const now = Math.floor(Date.now() / 1000); 279 280 const endState = { 281 ...mockState, 282 TimerWidget: { 283 ...mockState.TimerWidget, 284 focus: { 285 ...mockState.TimerWidget.break, 286 isRunning: true, 287 startTime: now - 300, 288 }, 289 }, 290 }; 291 292 wrapper = mount( 293 <WrapWithProvider state={endState}> 294 <FocusTimer 295 dispatch={dispatch} 296 handleUserInteraction={handleUserInteraction} 297 /> 298 </WrapWithProvider> 299 ); 300 301 // Let interval fire and start the timer_end logic 302 clock.tick(1000); 303 304 // Allowing time for the chained timeouts for animation 305 clock.tick(3000); 306 wrapper.update(); 307 308 const types = dispatch 309 .getCalls() 310 .map(call => call.args[0].type) 311 .filter(Boolean); 312 313 assert.ok(types.includes(at.WIDGETS_TIMER_END)); 314 assert.ok(types.includes(at.WIDGETS_TIMER_SET_TYPE)); 315 316 const findTypeToggled = dispatch 317 .getCalls() 318 .map(call => call.args[0]) 319 .find(action => action.type === at.WIDGETS_TIMER_SET_TYPE); 320 321 assert.equal( 322 findTypeToggled.data.timerType, 323 "break", 324 "timer should switch to break mode" 325 ); 326 }); 327 328 it("should toggle from break to focus timer automatically on end", () => { 329 const now = Math.floor(Date.now() / 1000); 330 331 const endState = { 332 ...mockState, 333 TimerWidget: { 334 ...mockState.TimerWidget, 335 timerType: "break", 336 break: { 337 ...mockState.TimerWidget.break, 338 isRunning: true, 339 startTime: now - 300, 340 }, 341 }, 342 }; 343 344 wrapper = mount( 345 <WrapWithProvider state={endState}> 346 <FocusTimer 347 dispatch={dispatch} 348 handleUserInteraction={handleUserInteraction} 349 /> 350 </WrapWithProvider> 351 ); 352 353 // Let interval fire and start the timer_end logic 354 clock.tick(1000); 355 356 // Allowing time for the chained timeouts for animation 357 clock.tick(3000); 358 wrapper.update(); 359 360 const types = dispatch.getCalls().map(call => call.args[0].type); 361 362 assert.ok(types.includes(at.WIDGETS_TIMER_END)); 363 assert.ok(types.includes(at.WIDGETS_TIMER_SET_TYPE)); 364 365 const findTypeToggled = dispatch 366 .getCalls() 367 .map(call => call.args[0]) 368 .find(action => action.type === at.WIDGETS_TIMER_SET_TYPE); 369 370 assert.equal( 371 findTypeToggled.data.timerType, 372 "focus", 373 "timer should switch to focus mode" 374 ); 375 }); 376 377 it("should pause when time input is focused", () => { 378 const activeState = { 379 ...mockState, 380 TimerWidget: { 381 ...mockState.TimerWidget, 382 timerType: "focus", 383 focus: { 384 ...mockState.TimerWidget.focus, 385 isRunning: true, 386 }, 387 }, 388 }; 389 390 const activeWrapper = mount( 391 <WrapWithProvider state={activeState}> 392 <FocusTimer 393 dispatch={dispatch} 394 handleUserInteraction={handleUserInteraction} 395 /> 396 </WrapWithProvider> 397 ); 398 399 const minutesSpan = activeWrapper.find(".timer-set-minutes").at(0); 400 assert.ok(minutesSpan.exists()); 401 402 minutesSpan.simulate("focus"); 403 assert.equal(dispatch.getCall(0).args[0].type, at.WIDGETS_TIMER_PAUSE); 404 }); 405 406 it("should reset to user's initial duration after timer ends", () => { 407 const now = Math.floor(Date.now() / 1000); 408 409 // mock up a user's initial duration (12 minutes) 410 const initialUserDuration = 12 * 60; 411 412 const endState = { 413 ...mockState, 414 TimerWidget: { 415 ...mockState.TimerWidget, 416 timerType: "focus", 417 focus: { 418 duration: initialUserDuration, 419 initialDuration: initialUserDuration, 420 startTime: now - initialUserDuration, 421 isRunning: true, 422 }, 423 }, 424 }; 425 426 wrapper = mount( 427 <WrapWithProvider state={endState}> 428 <FocusTimer 429 dispatch={dispatch} 430 handleUserInteraction={handleUserInteraction} 431 /> 432 </WrapWithProvider> 433 ); 434 435 // Let interval fire and start the timer_end logic 436 clock.tick(1000); 437 438 // Allowing time for the chained timeouts for animation 439 clock.tick(3000); 440 wrapper.update(); 441 442 const endCall = dispatch 443 .getCalls() 444 .map(call => call.args[0]) 445 .find(action => action.type === at.WIDGETS_TIMER_END); 446 447 assert.ok( 448 endCall, 449 "WIDGETS_TIMER_END should be dispatched when timer runs out" 450 ); 451 assert.equal( 452 endCall.data.duration, 453 initialUserDuration, 454 "timer should restore to user's initial input" 455 ); 456 457 assert.equal( 458 endCall.data.initialDuration, 459 initialUserDuration, 460 "initialDuration should also be restored to user's initial input" 461 ); 462 }); 463 464 it("should wait one second at zero before completing timer", () => { 465 const now = Math.floor(Date.now() / 1000); 466 467 const endState = { 468 ...mockState, 469 TimerWidget: { 470 ...mockState.TimerWidget, 471 timerType: "focus", 472 focus: { 473 duration: 300, 474 initialDuration: 300, 475 startTime: now - 300, 476 isRunning: true, 477 }, 478 }, 479 }; 480 481 wrapper = mount( 482 <WrapWithProvider state={endState}> 483 <FocusTimer 484 dispatch={dispatch} 485 handleUserInteraction={handleUserInteraction} 486 /> 487 </WrapWithProvider> 488 ); 489 490 // First interval tick - should reach zero but not complete 491 clock.tick(1000); 492 493 // Verify timer has not ended yet (no WIDGETS_TIMER_END dispatched) 494 const callsAfterFirstTick = dispatch 495 .getCalls() 496 .map(call => call.args[0]) 497 .filter(action => action && action.type === at.WIDGETS_TIMER_END); 498 499 assert.equal( 500 callsAfterFirstTick.length, 501 0, 502 "WIDGETS_TIMER_END should not be dispatched on first tick at zero" 503 ); 504 505 // Second interval tick - should now complete 506 clock.tick(1000); 507 508 // Allowing time for the chained timeouts for animation 509 clock.tick(2000); 510 wrapper.update(); 511 512 const endCall = dispatch 513 .getCalls() 514 .map(call => call.args[0]) 515 .find(action => action && action.type === at.WIDGETS_TIMER_END); 516 517 assert.ok( 518 endCall, 519 "WIDGETS_TIMER_END should be dispatched after one second at zero" 520 ); 521 }); 522 523 describe("context menu", () => { 524 it("should render default context menu", () => { 525 assert.ok(wrapper.find(".focus-timer-context-menu-button").exists()); 526 assert.ok(wrapper.find("#focus-timer-context-menu").exists()); 527 528 // "Turn notifications off" option 529 assert.ok( 530 wrapper 531 .find( 532 "panel-item[data-l10n-id='newtab-widget-timer-menu-notifications']" 533 ) 534 .exists() 535 ); 536 537 assert.ok( 538 wrapper 539 .find("panel-item[data-l10n-id='newtab-widget-timer-menu-hide']") 540 .exists() 541 ); 542 543 assert.ok( 544 wrapper 545 .find( 546 "panel-item[data-l10n-id='newtab-widget-timer-menu-learn-more']" 547 ) 548 .exists() 549 ); 550 551 // Make sure "Turn notifications on" is not there 552 assert.isFalse( 553 wrapper.contains( 554 "panel-item[data-l10n-id='newtab-widget-timer-menu-notifications-on']" 555 ) 556 ); 557 }); 558 559 it("should render context menu with 'turn notifications on' if notifications are disabled", () => { 560 const noNotificationsState = { 561 ...mockState, 562 Prefs: { 563 ...INITIAL_STATE.Prefs, 564 values: { 565 ...INITIAL_STATE.Prefs.values, 566 [PREF_WIDGETS_SYSTEM_NOTIFICATIONS_ENABLED]: false, 567 }, 568 }, 569 }; 570 571 wrapper = mount( 572 <WrapWithProvider state={noNotificationsState}> 573 <FocusTimer 574 dispatch={dispatch} 575 handleUserInteraction={handleUserInteraction} 576 /> 577 </WrapWithProvider> 578 ); 579 580 // "Turn notifications on" option 581 assert.ok( 582 wrapper 583 .find( 584 "panel-item[data-l10n-id='newtab-widget-timer-menu-notifications-on']" 585 ) 586 .exists() 587 ); 588 589 // Make sure "Turn notifications off" is not there 590 assert.isFalse( 591 wrapper.contains( 592 "panel-item[data-l10n-id='newtab-widget-timer-menu-notifications']" 593 ) 594 ); 595 }); 596 597 it("should turn off notifications when the 'Turn off notifications' option is clicked", () => { 598 const menuItem = wrapper.find( 599 "panel-item[data-l10n-id='newtab-widget-timer-menu-notifications']" 600 ); 601 menuItem.props().onClick(); 602 603 assert.ok(dispatch.calledOnce); 604 const [action] = dispatch.getCall(0).args; 605 assert.equal(action.type, at.SET_PREF); 606 }); 607 608 it("should hide Focus Timer when 'Hide timer' option is clicked", () => { 609 const menuItem = wrapper.find( 610 "panel-item[data-l10n-id='newtab-widget-timer-menu-hide']" 611 ); 612 menuItem.props().onClick(); 613 614 assert.ok(dispatch.calledOnce); 615 const [action] = dispatch.getCall(0).args; 616 assert.equal(action.type, at.SET_PREF); 617 }); 618 619 it("should dispatch OPEN_LINK when the Learn More option is clicked", () => { 620 const menuItem = wrapper.find( 621 "panel-item[data-l10n-id='newtab-widget-timer-menu-learn-more']" 622 ); 623 menuItem.props().onClick(); 624 625 assert.ok(dispatch.calledOnce); 626 const [action] = dispatch.getCall(0).args; 627 assert.equal(action.type, at.OPEN_LINK); 628 }); 629 }); 630 631 // Tests for the focus timer input. It should only allow numbers 632 describe("isNumericValue", () => { 633 it("should return true for single digit numbers", () => { 634 assert.isTrue(isNumericValue("0")); 635 assert.isTrue(isNumericValue("1")); 636 assert.isTrue(isNumericValue("5")); 637 assert.isTrue(isNumericValue("9")); 638 }); 639 640 it("should return true for multi-digit numbers", () => { 641 assert.isTrue(isNumericValue("10")); 642 assert.isTrue(isNumericValue("25")); 643 assert.isTrue(isNumericValue("99")); 644 }); 645 646 it("should return false for non-numeric characters", () => { 647 assert.isFalse(isNumericValue("a")); 648 assert.isFalse(isNumericValue("Z")); 649 assert.isFalse(isNumericValue("!")); 650 assert.isFalse(isNumericValue("@")); 651 assert.isFalse(isNumericValue(" ")); 652 }); 653 654 it("should return false for special characters", () => { 655 assert.isFalse(isNumericValue("-")); 656 assert.isFalse(isNumericValue("+")); 657 assert.isFalse(isNumericValue(".")); 658 assert.isFalse(isNumericValue(",")); 659 }); 660 661 it("should return false for mixed alphanumeric strings", () => { 662 assert.isFalse(isNumericValue("1a")); 663 assert.isFalse(isNumericValue("a1")); 664 assert.isFalse(isNumericValue("5x")); 665 }); 666 667 it("should return false for empty string", () => { 668 assert.isFalse(isNumericValue(" ")); 669 }); 670 }); 671 672 // Tests for the 2-character limit (enforces max 99 minutes, 59 seconds) 673 describe("isAtMaxLength", () => { 674 it("should return false for empty string", () => { 675 assert.isFalse(isAtMaxLength("")); 676 }); 677 678 it("should return false for single character", () => { 679 assert.isFalse(isAtMaxLength("5")); 680 assert.isFalse(isAtMaxLength("9")); 681 }); 682 683 it("should return true for 2 characters", () => { 684 assert.isTrue(isAtMaxLength("25")); 685 assert.isTrue(isAtMaxLength("99")); 686 assert.isTrue(isAtMaxLength("00")); 687 }); 688 689 it("should return true for more than 2 characters", () => { 690 assert.isTrue(isAtMaxLength("123")); 691 assert.isTrue(isAtMaxLength("999")); 692 }); 693 }); 694 695 it("should clamp minutes to 99 and seconds to 59 when setting duration", () => { 696 // Find the editable fields 697 const minutes = wrapper.find(".timer-set-minutes").at(0); 698 const seconds = wrapper.find(".timer-set-seconds").at(0); 699 700 // Simulate user typing values beyond limits 701 minutes.getDOMNode().innerText = "100"; 702 seconds.getDOMNode().innerText = "85"; 703 704 // Trigger blur, which calls setTimerDuration() 705 seconds.simulate("blur"); 706 707 // Clamp check 708 const clampedMinutes = Math.min( 709 parseInt(minutes.getDOMNode().innerText, 10), 710 99 711 ); 712 const clampedSeconds = Math.min( 713 parseInt(seconds.getDOMNode().innerText, 10), 714 59 715 ); 716 717 assert.equal(clampedMinutes, 99, "minutes should be clamped to 99"); 718 assert.equal(clampedSeconds, 59, "seconds should be clamped to 59"); 719 }); 720 });