tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 });