/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ import { fireEvent, render } from "@testing-library/react"; import { Provider } from "react-redux"; import { actionTypes as at } from "common/Actions.mjs"; import { SportsMatchRow } from "content-src/components/Widgets/SportsWidget/SportsMatchRow"; const baseMatch = { home_team: { key: "ENG", name: "England" }, away_team: { key: "USA", name: "United States" }, date: "2026-05-08T14:00:00+00:00", status_type: null, home_score: 1, away_score: 0, home_extra: null, away_extra: null, home_penalty: null, away_penalty: null, query: "England vs United States", }; // SportsMatchRow uses `useDispatch` and `useSelector`, which need a // Provider context. We give it a fake store whose `dispatch` is a jest // mock so we can assert on what got dispatched when a user clicks a row. // The fake state surfaces the widgets.sportsWidget.size pref because the // row reads it for telemetry. function renderWithDispatch(ui, { widgetSize = "large" } = {}) { const dispatch = jest.fn(); const store = { getState: () => ({ Prefs: { values: { "widgets.sportsWidget.size": widgetSize } }, }), subscribe: () => () => {}, dispatch, }; const result = render({ui}); return { ...result, dispatch }; } describe(" upcoming variant", () => { it("renders home and away team codes", () => { const { container } = renderWithDispatch( ); const codes = container.querySelectorAll(".sports-match-code"); expect(codes[0].textContent).toBe("ENG"); expect(codes[1].textContent).toBe("USA"); }); it("renders match time element", () => { const { container } = renderWithDispatch( ); expect( container.querySelector( "[data-l10n-id='newtab-sports-widget-match-time']" ) ).toBeInTheDocument(); }); it("renders match date when no special status", () => { const { container } = renderWithDispatch( ); expect( container.querySelector("[data-l10n-id='newtab-sports-widget-key-date']") ).toBeInTheDocument(); }); it("renders status label instead of date for a special status", () => { const { container } = renderWithDispatch( ); expect( container.querySelector("[data-l10n-id='newtab-sports-widget-postponed']") ).toBeInTheDocument(); expect( container.querySelector("[data-l10n-id='newtab-sports-widget-key-date']") ).not.toBeInTheDocument(); }); }); describe(" now variant", () => { it("renders home and away scores including extra time", () => { const { container } = renderWithDispatch( ); expect(container.querySelector(".sports-score-home").textContent).toBe("2"); expect(container.querySelector(".sports-score-away").textContent).toBe("0"); }); }); describe(" results variant", () => { it("renders home and away scores including extra time", () => { const { container } = renderWithDispatch( ); expect(container.querySelector(".sports-score-home").textContent).toBe("2"); expect(container.querySelector(".sports-score-away").textContent).toBe("0"); }); it("renders the Full time footer", () => { const { container } = renderWithDispatch( ); expect( container.querySelector( "[data-l10n-id='newtab-sports-widget-match-full-time']" ) ).toBeInTheDocument(); }); it("renders penalty scores and footer text when penalties are present", () => { const { container } = renderWithDispatch( ); const penalties = container.querySelectorAll(".sports-score-penalty"); expect(penalties[0].textContent).toBe("(4)"); expect(penalties[1].textContent).toBe("(3)"); expect( container.querySelector( "[data-l10n-id='newtab-sports-widget-match-penalties']" ) ).toBeInTheDocument(); }); it("does not render penalty elements when no penalties", () => { const { container } = renderWithDispatch( ); expect(container.querySelectorAll(".sports-score-penalty")).toHaveLength(0); expect( container.querySelector( "[data-l10n-id='newtab-sports-widget-match-penalties']" ) ).not.toBeInTheDocument(); }); }); describe(" aria-label l10n", () => { function getAnchorL10n(container) { const anchor = container.querySelector("a.sports-match-row"); return { id: anchor.getAttribute("data-l10n-id"), args: JSON.parse(anchor.getAttribute("data-l10n-args")), }; } describe("results variant", () => { it("uses the results l10n id for a normal full-time result", () => { const { container } = renderWithDispatch( ); const { id, args } = getAnchorL10n(container); expect(id).toBe("newtab-sports-widget-match-aria-label-results"); expect(args).toEqual({ homeTeam: "England", awayTeam: "United States", homeScore: 3, awayScore: 2, }); }); it("uses the results-penalties l10n id and includes penalty scores when the match went to a shootout", () => { const { container } = renderWithDispatch( ); const { id, args } = getAnchorL10n(container); expect(id).toBe( "newtab-sports-widget-match-aria-label-results-penalties" ); expect(args.homePenalty).toBe(5); expect(args.awayPenalty).toBe(4); }); it("adds extra-time goals into the announced scores", () => { const { container } = renderWithDispatch( ); const { args } = getAnchorL10n(container); expect(args.homeScore).toBe(3); expect(args.awayScore).toBe(1); }); it("treats home_penalty === 0 as a real shootout value, not a missing one", () => { // Guards against a regression where a falsy check on home_penalty // would route a 0-goal shootout to the non-penalties ID. const { container } = renderWithDispatch( ); const { id, args } = getAnchorL10n(container); expect(id).toBe( "newtab-sports-widget-match-aria-label-results-penalties" ); expect(args.homePenalty).toBe(0); expect(args.awayPenalty).toBe(3); }); }); describe("now variant", () => { it("uses the now l10n id with the current score", () => { const { container } = renderWithDispatch( ); const { id, args } = getAnchorL10n(container); expect(id).toBe("newtab-sports-widget-match-aria-label-now"); expect(args).toEqual( expect.objectContaining({ homeTeam: "England", awayTeam: "United States", homeScore: 1, awayScore: 0, }) ); }); }); describe("upcoming variant", () => { it("uses the upcoming l10n id and passes the date timestamp for a scheduled match", () => { const { container } = renderWithDispatch( ); const { id, args } = getAnchorL10n(container); expect(id).toBe("newtab-sports-widget-match-aria-label-upcoming"); expect(args.date).toBe(new Date(baseMatch.date).getTime()); }); it("falls back to the upcoming (scheduled) l10n id when status_type is missing", () => { // Defensive: API can omit status_type; the default scheduled string // must still be picked. const { container } = renderWithDispatch( ); expect(getAnchorL10n(container).id).toBe( "newtab-sports-widget-match-aria-label-upcoming" ); }); it.each([ ["delayed", "newtab-sports-widget-match-aria-label-upcoming-delayed"], ["postponed", "newtab-sports-widget-match-aria-label-upcoming-postponed"], ["suspended", "newtab-sports-widget-match-aria-label-upcoming-suspended"], ["cancelled", "newtab-sports-widget-match-aria-label-upcoming-cancelled"], ])( "picks the matching per-status l10n id when status_type is %s", (statusType, expectedId) => { const { container } = renderWithDispatch( ); expect(getAnchorL10n(container).id).toBe(expectedId); } ); }); }); describe(" click handling", () => { it("dispatches a newtab telemetry event and the open-search action on click", () => { const { container, dispatch } = renderWithDispatch( , { widgetSize: "medium" } ); fireEvent.click(container.querySelector("a.sports-match-row")); expect(dispatch).toHaveBeenCalledTimes(2); const [[userEvent], [openSearch]] = dispatch.mock.calls; // First dispatch: newtab-side telemetry. The widget_source is the row's // variant so we can attribute clicks to the right tab in analytics; the // widget_size comes from the pref (not the row's display `size`, which // can be "list"). expect(userEvent).toMatchObject({ type: at.WIDGETS_USER_EVENT, data: { widget_name: "sports", widget_source: "upcoming", user_action: "open_match_search", widget_size: "medium", }, }); // Second dispatch: the action the SportsFeed backend uses to call // SearchUIUtils.loadSearch. expect(openSearch).toMatchObject({ type: at.WIDGETS_SPORTS_OPEN_MATCH_SEARCH, data: { query: baseMatch.query, eventInfo: expect.objectContaining({ button: 0 }), }, }); }); it("propagates modifier key state in the dispatched eventInfo", () => { const { container, dispatch } = renderWithDispatch( ); fireEvent.click(container.querySelector("a.sports-match-row"), { metaKey: true, shiftKey: true, }); // calls[0] is the USER_EVENT; calls[1] is the OPEN_MATCH_SEARCH action // whose eventInfo carries the modifier state. const [, [openSearch]] = dispatch.mock.calls; expect(openSearch.data.eventInfo).toMatchObject({ metaKey: true, shiftKey: true, button: 0, }); }); it("treats Enter and Space key presses as activation", () => { // Anchors without href don't fire click on Enter/Space natively, so the // component wires keyboard activation up manually. Each activation // produces two dispatches (USER_EVENT + OPEN_MATCH_SEARCH). const { container, dispatch } = renderWithDispatch( ); const anchor = container.querySelector("a.sports-match-row"); fireEvent.keyDown(anchor, { key: "Enter" }); fireEvent.keyDown(anchor, { key: " " }); expect(dispatch).toHaveBeenCalledTimes(4); expect(dispatch.mock.calls[0][0].type).toBe(at.WIDGETS_USER_EVENT); expect(dispatch.mock.calls[1][0].type).toBe( at.WIDGETS_SPORTS_OPEN_MATCH_SEARCH ); expect(dispatch.mock.calls[2][0].type).toBe(at.WIDGETS_USER_EVENT); expect(dispatch.mock.calls[3][0].type).toBe( at.WIDGETS_SPORTS_OPEN_MATCH_SEARCH ); }); it("does not dispatch when the match has no query", () => { const { container, dispatch } = renderWithDispatch( ); fireEvent.click(container.querySelector("a.sports-match-row")); expect(dispatch).not.toHaveBeenCalled(); }); it("calls handleInteraction on click to mark the widget as interacted-with", () => { const handleInteraction = jest.fn(); const { container } = renderWithDispatch( ); fireEvent.click(container.querySelector("a.sports-match-row")); expect(handleInteraction).toHaveBeenCalledTimes(1); }); it("does not call handleInteraction when the match has no query", () => { const handleInteraction = jest.fn(); const { container } = renderWithDispatch( ); fireEvent.click(container.querySelector("a.sports-match-row")); expect(handleInteraction).not.toHaveBeenCalled(); }); it("applies link semantics only when the match has a query", () => { // With a query: the row claims a link role and is in the tab order. const { container: withQuery } = renderWithDispatch( ); const clickableAnchor = withQuery.querySelector("a.sports-match-row"); expect(clickableAnchor).toHaveAttribute("tabindex", "0"); expect(clickableAnchor).toHaveAttribute("role", "link"); expect(clickableAnchor.className).toContain("clickable"); // Without a query: no role, no tabindex, no `clickable` class — the // anchor is an inert wrapper so AT doesn't announce a phantom link. const { container: noQuery } = renderWithDispatch( ); const inertAnchor = noQuery.querySelector("a.sports-match-row"); expect(inertAnchor).not.toHaveAttribute("tabindex"); expect(inertAnchor).not.toHaveAttribute("role"); expect(inertAnchor.className).not.toContain("clickable"); }); }); describe(" followed teams", () => { function getFlagWrappers(container) { return container.querySelectorAll(".sports-match-flag-wrapper"); } function getCodes(container) { return container.querySelectorAll(".sports-match-code"); } it("does not mark either side as followed when followedTeams is undefined", () => { const { container } = renderWithDispatch( ); const wrappers = getFlagWrappers(container); expect(wrappers[0].classList.contains("is-followed")).toBe(false); expect(wrappers[1].classList.contains("is-followed")).toBe(false); expect(container.querySelectorAll(".sports-match-flag-check")).toHaveLength( 0 ); const codes = getCodes(container); expect(codes[0].querySelector("strong")).toBeNull(); expect(codes[1].querySelector("strong")).toBeNull(); }); it("marks only the home side when only the home team is followed", () => { const { container } = renderWithDispatch( ); const wrappers = getFlagWrappers(container); expect(wrappers[0].classList.contains("is-followed")).toBe(true); expect(wrappers[1].classList.contains("is-followed")).toBe(false); const checks = container.querySelectorAll(".sports-match-flag-check"); expect(checks).toHaveLength(1); expect(wrappers[0].querySelector(".sports-match-flag-check")).toBeTruthy(); const codes = getCodes(container); expect(codes[0].querySelector("strong").textContent).toBe("ENG"); expect(codes[1].querySelector("strong")).toBeNull(); }); it("marks only the away side when only the away team is followed", () => { const { container } = renderWithDispatch( ); const wrappers = getFlagWrappers(container); expect(wrappers[0].classList.contains("is-followed")).toBe(false); expect(wrappers[1].classList.contains("is-followed")).toBe(true); expect(wrappers[1].querySelector(".sports-match-flag-check")).toBeTruthy(); const codes = getCodes(container); expect(codes[0].querySelector("strong")).toBeNull(); expect(codes[1].querySelector("strong").textContent).toBe("USA"); }); it("marks both sides when both teams are followed", () => { const { container } = renderWithDispatch( ); const wrappers = getFlagWrappers(container); expect(wrappers[0].classList.contains("is-followed")).toBe(true); expect(wrappers[1].classList.contains("is-followed")).toBe(true); expect(container.querySelectorAll(".sports-match-flag-check")).toHaveLength( 2 ); }); it("hides the check badge from assistive technology", () => { const { container } = renderWithDispatch( ); expect( container .querySelector(".sports-match-flag-check") .getAttribute("aria-hidden") ).toBe("true"); }); }); describe(" flag accessibility", () => { it("sets alt and title on the home team flag image to the team name", () => { const { container } = renderWithDispatch( ); const flags = container.querySelectorAll(".sports-match-flag"); expect(flags[0].getAttribute("alt")).toBe("England"); expect(flags[0].getAttribute("title")).toBe("England"); }); it("sets alt and title on the away team flag image to the team name", () => { const { container } = renderWithDispatch( ); const flags = container.querySelectorAll(".sports-match-flag"); expect(flags[1].getAttribute("alt")).toBe("United States"); expect(flags[1].getAttribute("title")).toBe("United States"); }); it("does not set title on the parent .sports-match-team div", () => { const { container } = renderWithDispatch( ); const teamDivs = container.querySelectorAll(".sports-match-team"); expect(teamDivs[0].getAttribute("title")).toBeNull(); expect(teamDivs[1].getAttribute("title")).toBeNull(); }); });