tor-browser

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

CardSections.test.jsx (15394B)


      1 import React from "react";
      2 import { mount } from "enzyme";
      3 import { Provider } from "react-redux";
      4 import { INITIAL_STATE, reducers } from "common/Reducers.sys.mjs";
      5 import { CardSections } from "content-src/components/DiscoveryStreamComponents/CardSections/CardSections";
      6 import { combineReducers, createStore } from "redux";
      7 import { DSCard } from "../../../../../content-src/components/DiscoveryStreamComponents/DSCard/DSCard";
      8 import { FollowSectionButtonHighlight } from "../../../../../content-src/components/DiscoveryStreamComponents/FeatureHighlight/FollowSectionButtonHighlight";
      9 
     10 const PREF_SECTIONS_PERSONALIZATION_ENABLED =
     11  "discoverystream.sections.personalization.enabled";
     12 
     13 const DEFAULT_PROPS = {
     14  type: "CardGrid",
     15  firstVisibleTimeStamp: null,
     16  ctaButtonSponsors: [""],
     17  anySectionsFollowed: false,
     18  data: {
     19    sections: [
     20      {
     21        data: [
     22          {
     23            title: "Card 1",
     24            image_src: "image1.jpg",
     25            url: "http://example.com",
     26          },
     27          {},
     28          {},
     29          {},
     30        ],
     31        receivedRank: 0,
     32        sectionKey: "section_key",
     33        title: "title",
     34        layout: {
     35          title: "layout_name",
     36          responsiveLayouts: [
     37            {
     38              columnCount: 1,
     39              tiles: [
     40                {
     41                  size: "large",
     42                  position: 0,
     43                  hasAd: false,
     44                  hasExcerpt: true,
     45                },
     46                {
     47                  size: "small",
     48                  position: 2,
     49                  hasAd: false,
     50                  hasExcerpt: false,
     51                },
     52                {
     53                  size: "medium",
     54                  position: 1,
     55                  hasAd: true,
     56                  hasExcerpt: true,
     57                },
     58                {
     59                  size: "small",
     60                  position: 3,
     61                  hasAd: false,
     62                  hasExcerpt: false,
     63                },
     64              ],
     65            },
     66          ],
     67        },
     68      },
     69    ],
     70  },
     71  feed: {
     72    embed_reference: null,
     73    url: "https://merino.services.mozilla.com/api/v1/curated-recommendations",
     74  },
     75 };
     76 
     77 // Wrap this around any component that uses useSelector,
     78 // or any mount that uses a child that uses redux.
     79 function WrapWithProvider({ children, state = INITIAL_STATE }) {
     80  let store = createStore(combineReducers(reducers), state);
     81  return <Provider store={store}>{children}</Provider>;
     82 }
     83 
     84 describe("<CardSections />", () => {
     85  let wrapper;
     86  let sandbox;
     87  let dispatch;
     88 
     89  beforeEach(() => {
     90    sandbox = sinon.createSandbox();
     91    dispatch = sandbox.stub();
     92    wrapper = mount(
     93      <WrapWithProvider>
     94        <CardSections dispatch={dispatch} {...DEFAULT_PROPS} />
     95      </WrapWithProvider>
     96    );
     97  });
     98 
     99  afterEach(() => {
    100    sandbox.restore();
    101  });
    102 
    103  it("should render null if no data is provided", () => {
    104    // Verify the section exists normally, so the next assertion is unlikely to be a false positive.
    105    assert(wrapper.find(".ds-section-wrapper").exists());
    106 
    107    wrapper = mount(
    108      <WrapWithProvider>
    109        <CardSections dispatch={dispatch} {...DEFAULT_PROPS} data={null} />
    110      </WrapWithProvider>
    111    );
    112    assert(!wrapper.find(".ds-section-wrapper").exists());
    113  });
    114 
    115  it("should render DSEmptyState if sections are falsey", () => {
    116    wrapper = mount(
    117      <WrapWithProvider>
    118        <CardSections
    119          {...DEFAULT_PROPS}
    120          data={{ ...DEFAULT_PROPS.data, sections: [] }}
    121        />
    122      </WrapWithProvider>
    123    );
    124    assert(wrapper.find(".ds-card-grid.empty").exists());
    125  });
    126 
    127  it("should render sections and DSCard components for valid data", () => {
    128    const { sections } = DEFAULT_PROPS.data;
    129    const sectionLength = sections.length;
    130    assert.lengthOf(wrapper.find("section"), sectionLength);
    131    assert.lengthOf(wrapper.find(DSCard), 4);
    132    assert.equal(wrapper.find(".section-title").text(), "title");
    133  });
    134 
    135  it("should skip a section with no items available for that section", () => {
    136    // Verify the section exists normally, so the next assertion is unlikely to be a false positive.
    137    assert(wrapper.find(".ds-section").exists());
    138 
    139    wrapper = mount(
    140      <WrapWithProvider>
    141        <CardSections
    142          {...DEFAULT_PROPS}
    143          data={{
    144            ...DEFAULT_PROPS.data,
    145            sections: [{ ...DEFAULT_PROPS.data.sections[0], data: [] }],
    146          }}
    147        />
    148      </WrapWithProvider>
    149    );
    150    assert(!wrapper.find(".ds-section").exists());
    151  });
    152 
    153  it("should render a placeholder", () => {
    154    wrapper = mount(
    155      <WrapWithProvider>
    156        <CardSections
    157          {...DEFAULT_PROPS}
    158          data={{
    159            ...DEFAULT_PROPS.data,
    160            sections: [
    161              {
    162                ...DEFAULT_PROPS.data.sections[0],
    163                data: [{ placeholder: true }],
    164              },
    165            ],
    166          }}
    167        />
    168      </WrapWithProvider>
    169    );
    170    assert(wrapper.find(".ds-card.placeholder").exists());
    171  });
    172 
    173  it("should pass correct props to DSCard", () => {
    174    const cardProps = wrapper.find(DSCard).at(0).props();
    175    assert.equal(cardProps.title, "Card 1");
    176    assert.equal(cardProps.image_src, "image1.jpg");
    177    assert.equal(cardProps.url, "http://example.com");
    178  });
    179 
    180  it("should apply correct classNames and position from layout data", () => {
    181    const props = wrapper.find(DSCard).at(0).props();
    182    const thirdProps = wrapper.find(DSCard).at(2).props();
    183    assert.equal(
    184      props.sectionsClassNames,
    185      "col-1-large col-1-position-0 col-1-show-excerpt"
    186    );
    187    assert.equal(
    188      thirdProps.sectionsClassNames,
    189      "col-1-small col-1-position-1 col-1-hide-excerpt"
    190    );
    191  });
    192 
    193  it("should apply correct class names for cards with and without excerpts", () => {
    194    wrapper.find(DSCard).forEach(card => {
    195      const props = card.props();
    196      // Small cards don't show excerpts according to the data in DEFAULT_PROPS for this test suite
    197      if (props.sectionsClassNames.includes("small")) {
    198        assert.include(props.sectionsClassNames, "hide-excerpt");
    199        assert.notInclude(props.sectionsClassNames, "show-excerpt");
    200      }
    201      // The other cards should show excerpts though!
    202      else {
    203        assert.include(props.sectionsClassNames, "show-excerpt");
    204        assert.notInclude(props.sectionsClassNames, "hide-excerpt");
    205      }
    206    });
    207  });
    208 
    209  it("should dispatch SECTION_PERSONALIZATION_UPDATE updates with follow and unfollow", () => {
    210    const fakeDate = "2020-01-01T00:00:00.000Z";
    211    sandbox.useFakeTimers(new Date(fakeDate));
    212    const layout = {
    213      title: "layout_name",
    214      responsiveLayouts: [
    215        {
    216          columnCount: 1,
    217          tiles: [
    218            {
    219              size: "large",
    220              position: 0,
    221              hasAd: false,
    222              hasExcerpt: true,
    223            },
    224            {
    225              size: "small",
    226              position: 2,
    227              hasAd: false,
    228              hasExcerpt: false,
    229            },
    230            {
    231              size: "medium",
    232              position: 1,
    233              hasAd: true,
    234              hasExcerpt: true,
    235            },
    236            {
    237              size: "small",
    238              position: 3,
    239              hasAd: false,
    240              hasExcerpt: false,
    241            },
    242          ],
    243        },
    244      ],
    245    };
    246    // mock the pref for followed section
    247    const state = {
    248      ...INITIAL_STATE,
    249      DiscoveryStream: {
    250        ...INITIAL_STATE.DiscoveryStream,
    251        sectionPersonalization: {
    252          section_key_2: {
    253            isFollowed: true,
    254            isBlocked: false,
    255          },
    256        },
    257      },
    258      Prefs: {
    259        ...INITIAL_STATE.Prefs,
    260        values: {
    261          ...INITIAL_STATE.Prefs.values,
    262          [PREF_SECTIONS_PERSONALIZATION_ENABLED]: true,
    263        },
    264      },
    265    };
    266 
    267    wrapper = mount(
    268      <WrapWithProvider state={state}>
    269        <CardSections
    270          dispatch={dispatch}
    271          {...DEFAULT_PROPS}
    272          data={{
    273            ...DEFAULT_PROPS.data,
    274            sections: [
    275              {
    276                data: [
    277                  {
    278                    title: "Card 1",
    279                    image_src: "image1.jpg",
    280                    url: "http://example.com",
    281                  },
    282                ],
    283                receivedRank: 0,
    284                sectionKey: "section_key_1",
    285                title: "title",
    286                layout,
    287              },
    288              {
    289                data: [
    290                  {
    291                    title: "Card 2",
    292                    image_src: "image2.jpg",
    293                    url: "http://example.com",
    294                  },
    295                ],
    296                receivedRank: 0,
    297                sectionKey: "section_key_2",
    298                title: "title",
    299                layout,
    300              },
    301            ],
    302          }}
    303        />
    304      </WrapWithProvider>
    305    );
    306 
    307    let button = wrapper.find(".section-follow moz-button").first();
    308    button.simulate("click", {});
    309 
    310    assert.deepEqual(dispatch.getCall(0).firstArg, {
    311      type: "SECTION_PERSONALIZATION_SET",
    312      data: {
    313        section_key_2: {
    314          isFollowed: true,
    315          isBlocked: false,
    316        },
    317        section_key_1: {
    318          isFollowed: true,
    319          isBlocked: false,
    320          followedAt: fakeDate,
    321        },
    322      },
    323      meta: {
    324        from: "ActivityStream:Content",
    325        to: "ActivityStream:Main",
    326      },
    327    });
    328 
    329    assert.calledWith(dispatch.getCall(1), {
    330      type: "FOLLOW_SECTION",
    331      data: {
    332        section: "section_key_1",
    333        section_position: 0,
    334        event_source: "MOZ_BUTTON",
    335      },
    336      meta: {
    337        from: "ActivityStream:Content",
    338        to: "ActivityStream:Main",
    339        skipLocal: true,
    340      },
    341    });
    342 
    343    button = wrapper.find(".section-follow.following moz-button");
    344    button.simulate("click", {});
    345 
    346    assert.calledWith(dispatch.getCall(2), {
    347      type: "SECTION_PERSONALIZATION_SET",
    348      data: {},
    349      meta: {
    350        from: "ActivityStream:Content",
    351        to: "ActivityStream:Main",
    352      },
    353    });
    354 
    355    assert.calledWith(dispatch.getCall(3), {
    356      type: "UNFOLLOW_SECTION",
    357      data: {
    358        section: "section_key_2",
    359        section_position: 1,
    360        event_source: "MOZ_BUTTON",
    361      },
    362      meta: {
    363        from: "ActivityStream:Content",
    364        to: "ActivityStream:Main",
    365        skipLocal: true,
    366      },
    367    });
    368  });
    369 
    370  it("should render <FollowSectionButtonHighlight> when conditions match", () => {
    371    const fakeMessageData = {
    372      content: {
    373        messageType: "FollowSectionButtonHighlight",
    374      },
    375    };
    376 
    377    const layout = {
    378      title: "layout_name",
    379      responsiveLayouts: [
    380        {
    381          columnCount: 1,
    382          tiles: [{ size: "large", position: 0, hasExcerpt: true }],
    383        },
    384      ],
    385    };
    386 
    387    const state = {
    388      ...INITIAL_STATE,
    389      DiscoveryStream: {
    390        ...INITIAL_STATE.DiscoveryStream,
    391        sectionPersonalization: {}, // no sections followed
    392      },
    393      Prefs: {
    394        ...INITIAL_STATE.Prefs,
    395        values: {
    396          ...INITIAL_STATE.Prefs.values,
    397          [PREF_SECTIONS_PERSONALIZATION_ENABLED]: true,
    398        },
    399      },
    400      Messages: {
    401        isVisible: true,
    402        messageData: fakeMessageData,
    403      },
    404    };
    405 
    406    wrapper = mount(
    407      <WrapWithProvider state={state}>
    408        <CardSections
    409          dispatch={dispatch}
    410          {...DEFAULT_PROPS}
    411          data={{
    412            ...DEFAULT_PROPS.data,
    413            sections: [
    414              {
    415                data: [
    416                  {
    417                    title: "Card 1",
    418                    image_src: "image1.jpg",
    419                    url: "http://example.com",
    420                  },
    421                ],
    422                receivedRank: 0,
    423                sectionKey: "section_key_1",
    424                title: "title",
    425                layout,
    426              },
    427              {
    428                data: [
    429                  {
    430                    title: "Card 2",
    431                    image_src: "image2.jpg",
    432                    url: "http://example.com",
    433                  },
    434                ],
    435                receivedRank: 0,
    436                sectionKey: "section_key_2",
    437                title: "title",
    438                layout,
    439              },
    440            ],
    441          }}
    442        />
    443      </WrapWithProvider>
    444    );
    445 
    446    // Should only render for the second section (index 1)
    447    const highlight = wrapper.find(FollowSectionButtonHighlight);
    448    assert.equal(highlight.length, 1);
    449    assert.isTrue(wrapper.html().includes("follow-section-button-highlight"));
    450  });
    451 
    452  describe("Keyboard navigation", () => {
    453    beforeEach(() => {
    454      // Mock window.innerWidth to return a value that will make getActiveColumnLayout return "col-1"
    455      Object.defineProperty(window, "innerWidth", {
    456        writable: true,
    457        configurable: true,
    458        value: 500,
    459      });
    460    });
    461 
    462    it("should pass tabIndex={0} to the first card and tabIndex={-1} to other cards", () => {
    463      const firstCard = wrapper.find(DSCard).at(0);
    464      const secondCard = wrapper.find(DSCard).at(1);
    465      const thirdCard = wrapper.find(DSCard).at(2);
    466 
    467      assert.equal(firstCard.prop("tabIndex"), 0);
    468      assert.equal(secondCard.prop("tabIndex"), -1);
    469      assert.equal(thirdCard.prop("tabIndex"), -1);
    470    });
    471 
    472    it("should update focused index when onFocus is called", () => {
    473      const secondCard = wrapper.find(DSCard).at(1);
    474      const onFocus = secondCard.prop("onFocus");
    475 
    476      onFocus();
    477      wrapper.update();
    478 
    479      assert.equal(wrapper.find(DSCard).at(1).prop("tabIndex"), 0);
    480      assert.equal(wrapper.find(DSCard).at(0).prop("tabIndex"), -1);
    481    });
    482 
    483    describe("handleCardKeyDown", () => {
    484      let grid;
    485      let mockLink;
    486      let mockTargetCard;
    487      let mockGridElement;
    488      let mockCurrentCard;
    489      let mockEvent;
    490 
    491      beforeEach(() => {
    492        grid = wrapper.find(".ds-section-grid.ds-card-grid");
    493        mockLink = { focus: sandbox.spy() };
    494        mockTargetCard = {
    495          querySelector: sandbox.stub().returns(mockLink),
    496        };
    497        mockGridElement = {
    498          querySelector: sandbox.stub().returns(mockTargetCard),
    499        };
    500        mockCurrentCard = {
    501          parentElement: mockGridElement,
    502        };
    503        mockEvent = {
    504          preventDefault: sandbox.spy(),
    505          target: {
    506            closest: sandbox.stub().returns(mockCurrentCard),
    507          },
    508        };
    509      });
    510 
    511      afterEach(() => {
    512        sandbox.restore();
    513      });
    514 
    515      it("should navigate to next card with ArrowRight", () => {
    516        mockEvent.key = "ArrowRight";
    517        mockCurrentCard.classList = ["col-1-position-0"];
    518 
    519        grid.prop("onKeyDown")(mockEvent);
    520 
    521        assert.calledOnce(mockEvent.preventDefault);
    522        assert.calledWith(
    523          mockGridElement.querySelector,
    524          "article.ds-card.col-1-position-1"
    525        );
    526        assert.calledOnce(mockLink.focus);
    527      });
    528 
    529      it("should navigate to previous card with ArrowLeft", () => {
    530        mockEvent.key = "ArrowLeft";
    531        mockCurrentCard.classList = ["col-1-position-1"];
    532 
    533        grid.prop("onKeyDown")(mockEvent);
    534 
    535        assert.calledOnce(mockEvent.preventDefault);
    536        assert.calledWith(
    537          mockGridElement.querySelector,
    538          "article.ds-card.col-1-position-0"
    539        );
    540        assert.calledOnce(mockLink.focus);
    541      });
    542    });
    543  });
    544 });