/* 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();
});
});