/* 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 { act, fireEvent, render } from "@testing-library/react";
import { Provider } from "react-redux";
import { combineReducers, createStore } from "redux";
import { INITIAL_STATE, reducers } from "common/Reducers.sys.mjs";
import {
LOGO_VARIATIONS,
Logo,
PREF_LOGO_VARIATION,
pickVariant,
} from "content-src/components/Logo/Logo";
function renderWithPrefs(node, prefOverrides = {}) {
const state = {
...INITIAL_STATE,
Prefs: {
...INITIAL_STATE.Prefs,
values: {
...INITIAL_STATE.Prefs.values,
// All variation tests assume the Sports Widget is enabled; the
// logo variations are gated on it in Logo.jsx via
// `isWidgetEnabled(sportsWidget, prefs, widgetsEnabled)`, which
// requires the widgets master toggle, the system-side enabled
// pref, and the per-widget user pref all to be truthy.
// Individual tests can override via `prefOverrides`.
"widgets.enabled": true,
"widgets.system.sportsWidget.enabled": true,
"widgets.sportsWidget.enabled": true,
...prefOverrides,
},
},
};
const store = createStore(combineReducers(reducers), state);
return render({node});
}
function setMatchMediaMock({ reduceMotion = false } = {}) {
globalThis.matchMedia = query => ({
matches: query === "(prefers-reduced-motion: reduce)" ? reduceMotion : true,
addListener() {},
removeListener() {},
addEventListener() {},
removeEventListener() {},
});
}
describe("", () => {
let originalMatchMedia;
let originalDir;
beforeEach(() => {
originalMatchMedia = globalThis.matchMedia;
originalDir = document.dir;
setMatchMediaMock();
document.dir = "ltr";
});
afterEach(() => {
globalThis.matchMedia = originalMatchMedia;
document.dir = originalDir;
});
it("renders the standard logo wrapper", () => {
const { container } = renderWithPrefs();
expect(
container.querySelector("h1.logo-and-wordmark-wrapper")
).toBeInTheDocument();
expect(
container.querySelector("div.logo-and-wordmark")
).toBeInTheDocument();
expect(container.querySelector("div.wordmark")).toBeInTheDocument();
});
it("renders the default logo when no variation is configured", () => {
const { container } = renderWithPrefs();
expect(container.querySelector("div.logo")).toBeInTheDocument();
expect(container.querySelector(".spin-ball-small")).not.toBeInTheDocument();
expect(container.querySelector(".spin-smooth")).not.toBeInTheDocument();
});
// @backward-compat { version 153 }
// The describe block below can be removed after Firefox 153 hits Release.
describe("logo variation selection", () => {
it("renders SpinBallSmall when trainhopConfig.logo.variation is set", () => {
const { container } = renderWithPrefs(, {
trainhopConfig: { logo: { variation: "spin-ball-small" } },
});
expect(container.querySelector(".spin-ball-small")).toBeInTheDocument();
expect(container.querySelector("div.logo")).not.toBeInTheDocument();
});
it("renders SpinBallSmall when PREF_LOGO_VARIATION is set", () => {
const { container } = renderWithPrefs(, {
[PREF_LOGO_VARIATION]: "spin-ball-small",
});
expect(container.querySelector(".spin-ball-small")).toBeInTheDocument();
expect(container.querySelector("div.logo")).not.toBeInTheDocument();
});
it("renders SpinSmooth when PREF_LOGO_VARIATION is 'spin-smooth'", () => {
const { container } = renderWithPrefs(, {
[PREF_LOGO_VARIATION]: "spin-smooth",
});
expect(container.querySelector(".spin-smooth")).toBeInTheDocument();
expect(container.querySelector("div.logo")).not.toBeInTheDocument();
});
it("prefers trainhopConfig over PREF_LOGO_VARIATION", () => {
const { container } = renderWithPrefs(, {
trainhopConfig: { logo: { variation: "spin-ball-small" } },
[PREF_LOGO_VARIATION]: "this-id-does-not-exist",
});
expect(container.querySelector(".spin-ball-small")).toBeInTheDocument();
});
it("falls back to the pref when trainhopConfig is empty string", () => {
const { container } = renderWithPrefs(, {
trainhopConfig: { logo: { variation: "" } },
[PREF_LOGO_VARIATION]: "spin-ball-small",
});
expect(container.querySelector(".spin-ball-small")).toBeInTheDocument();
});
it("falls back to default logo when variation ID is unknown", () => {
const { container } = renderWithPrefs(, {
[PREF_LOGO_VARIATION]: "this-id-does-not-exist",
});
expect(container.querySelector("div.logo")).toBeInTheDocument();
expect(
container.querySelector(".spin-ball-small")
).not.toBeInTheDocument();
});
it("falls back to default logo when the Sports Widget is disabled, regardless of variation", () => {
const { container } = renderWithPrefs(, {
trainhopConfig: { logo: { variation: "spin-ball-small" } },
"widgets.sportsWidget.enabled": false,
});
expect(container.querySelector("div.logo")).toBeInTheDocument();
expect(
container.querySelector(".spin-ball-small")
).not.toBeInTheDocument();
});
it("still renders the variation when prefers-reduced-motion: reduce (motion is suppressed at click time, not in selection)", () => {
setMatchMediaMock({ reduceMotion: true });
const { container } = renderWithPrefs(, {
trainhopConfig: { logo: { variation: "spin-ball-small" } },
});
expect(container.querySelector(".spin-ball-small")).toBeInTheDocument();
expect(container.querySelector("div.logo")).not.toBeInTheDocument();
});
});
});
// @backward-compat { version 153 }
// All of the describe blocks below can be removed after Firefox 153 hits
// Release.
describe("pickVariant", () => {
const env = { viewportWidth: 1920, isLTR: true };
it("returns null for an empty or null variation ID", () => {
expect(pickVariant(null, env)).toBeNull();
expect(pickVariant("", env)).toBeNull();
expect(pickVariant(undefined, env)).toBeNull();
});
it("returns null for an unknown variation ID", () => {
expect(pickVariant("this-id-does-not-exist", env)).toBeNull();
});
it("returns the variation entry when constraints are satisfied", () => {
expect(pickVariant("spin-ball-small", env)).toBe(
LOGO_VARIATIONS["spin-ball-small"]
);
});
describe("constraint gating + fallback chain", () => {
const TEMP_ID = "test-only-temp-variant";
const HEAD_ID = "test-only-chain-head";
const TempComponent = () => null;
const HeadComponent = () => null;
afterEach(() => {
delete LOGO_VARIATIONS[TEMP_ID];
delete LOGO_VARIATIONS[HEAD_ID];
});
it("falls back when the width gate fails", () => {
LOGO_VARIATIONS[TEMP_ID] = {
component: TempComponent,
minViewportWidth: 5000,
requiresLTR: false,
fallback: null,
};
expect(
pickVariant(TEMP_ID, { viewportWidth: 1024, isLTR: true })
).toBeNull();
});
it("falls back when an LTR-required variation is rendered RTL", () => {
LOGO_VARIATIONS[TEMP_ID] = {
component: TempComponent,
minViewportWidth: 0,
requiresLTR: true,
fallback: null,
};
expect(
pickVariant(TEMP_ID, { viewportWidth: 1024, isLTR: false })
).toBeNull();
});
it("walks the fallback chain to a satisfying variation", () => {
LOGO_VARIATIONS[HEAD_ID] = {
component: HeadComponent,
minViewportWidth: 5000,
requiresLTR: false,
fallback: "spin-ball-small",
};
expect(pickVariant(HEAD_ID, { viewportWidth: 1024, isLTR: true })).toBe(
LOGO_VARIATIONS["spin-ball-small"]
);
});
it("terminates on cycles without infinite-looping", () => {
LOGO_VARIATIONS[TEMP_ID] = {
component: TempComponent,
minViewportWidth: 5000,
requiresLTR: false,
fallback: HEAD_ID,
};
LOGO_VARIATIONS[HEAD_ID] = {
component: HeadComponent,
minViewportWidth: 5000,
requiresLTR: false,
fallback: TEMP_ID,
};
expect(
pickVariant(HEAD_ID, { viewportWidth: 1024, isLTR: true })
).toBeNull();
});
});
});
describe("", () => {
let originalMatchMedia;
beforeEach(() => {
originalMatchMedia = globalThis.matchMedia;
setMatchMediaMock();
});
afterEach(() => {
globalThis.matchMedia = originalMatchMedia;
});
function renderSpinBallSmall() {
return renderWithPrefs(, {
[PREF_LOGO_VARIATION]: "spin-ball-small",
});
}
it("renders an aria-hidden SVG with the shared and variation classes", () => {
const { container } = renderSpinBallSmall();
const svg = container.querySelector("svg.spin-ball-small");
expect(svg).toBeInTheDocument();
expect(svg.getAttribute("aria-hidden")).toBe("true");
expect(svg.classList.contains("logo-variation-small")).toBe(true);
});
it("plays animations on first click, resetting currentTime to 0", () => {
const { container } = renderSpinBallSmall();
const svg = container.querySelector("svg.spin-ball-small");
const anim = { playState: "paused", play: jest.fn(), currentTime: 999 };
svg.getAnimations = jest.fn().mockReturnValue([anim]);
fireEvent.click(svg);
expect(anim.currentTime).toBe(0);
expect(anim.play).toHaveBeenCalledTimes(1);
});
it("ignores clicks while animations are running", () => {
const { container } = renderSpinBallSmall();
const svg = container.querySelector("svg.spin-ball-small");
const anim = { playState: "running", play: jest.fn(), currentTime: 1.5 };
svg.getAnimations = jest.fn().mockReturnValue([anim]);
fireEvent.click(svg);
expect(anim.play).not.toHaveBeenCalled();
expect(anim.currentTime).toBe(1.5);
});
it("restarts animations on click after they finish", () => {
const { container } = renderSpinBallSmall();
const svg = container.querySelector("svg.spin-ball-small");
const anim = { playState: "finished", play: jest.fn(), currentTime: 5.03 };
svg.getAnimations = jest.fn().mockReturnValue([anim]);
fireEvent.click(svg);
expect(anim.currentTime).toBe(0);
expect(anim.play).toHaveBeenCalledTimes(1);
});
it("does nothing when getAnimations returns an empty list", () => {
const { container } = renderSpinBallSmall();
const svg = container.querySelector("svg.spin-ball-small");
svg.getAnimations = jest.fn().mockReturnValue([]);
expect(() => fireEvent.click(svg)).not.toThrow();
});
it("does not play animations when prefers-reduced-motion is set, but stays clickable", () => {
setMatchMediaMock({ reduceMotion: true });
const { container } = renderSpinBallSmall();
const svg = container.querySelector("svg.spin-ball-small");
const anim = { playState: "paused", play: jest.fn(), currentTime: 0 };
svg.getAnimations = jest.fn().mockReturnValue([anim]);
expect(() => fireEvent.click(svg)).not.toThrow();
expect(anim.play).not.toHaveBeenCalled();
});
it("applies .is-animating while CSS animations are running", () => {
const { container } = renderSpinBallSmall();
const svg = container.querySelector("svg.spin-ball-small");
expect(svg.classList.contains("is-animating")).toBe(false);
act(() => {
svg.dispatchEvent(new Event("animationstart", { bubbles: true }));
});
expect(svg.classList.contains("is-animating")).toBe(true);
act(() => {
svg.dispatchEvent(new Event("animationend", { bubbles: true }));
});
expect(svg.classList.contains("is-animating")).toBe(false);
});
});
// @backward-compat { version 153 }
// Can be removed after Firefox 153 hits Release.
describe("", () => {
let originalMatchMedia;
beforeEach(() => {
originalMatchMedia = globalThis.matchMedia;
setMatchMediaMock();
});
afterEach(() => {
globalThis.matchMedia = originalMatchMedia;
});
function renderSpinSmooth() {
return renderWithPrefs(, {
[PREF_LOGO_VARIATION]: "spin-smooth",
});
}
it("renders an aria-hidden SVG with the shared and variation classes", () => {
const { container } = renderSpinSmooth();
const svg = container.querySelector("svg.spin-smooth");
expect(svg).toBeInTheDocument();
expect(svg.getAttribute("aria-hidden")).toBe("true");
expect(svg.classList.contains("logo-variation-small")).toBe(true);
});
it("renders an animateTransform with begin=indefinite (manual trigger)", () => {
const { container } = renderSpinSmooth();
const anim = container.querySelector("animateTransform");
expect(anim).toBeInTheDocument();
expect(anim.getAttribute("begin")).toBe("indefinite");
expect(anim.getAttribute("dur")).toBe("6.67s");
expect(anim.getAttribute("calcMode")).toBe("discrete");
});
it("calls beginElement() on the animation on click", () => {
const { container } = renderSpinSmooth();
const svg = container.querySelector("svg.spin-smooth");
const animNode = container.querySelector("animateTransform");
animNode.beginElement = jest.fn();
fireEvent.click(svg);
expect(animNode.beginElement).toHaveBeenCalledTimes(1);
});
it("ignores clicks while the animation is running", () => {
const { container } = renderSpinSmooth();
const svg = container.querySelector("svg.spin-smooth");
const animNode = container.querySelector("animateTransform");
animNode.beginElement = jest.fn();
animNode.dispatchEvent(new Event("beginEvent"));
fireEvent.click(svg);
expect(animNode.beginElement).not.toHaveBeenCalled();
});
it("allows replay after the animation finishes", () => {
const { container } = renderSpinSmooth();
const svg = container.querySelector("svg.spin-smooth");
const animNode = container.querySelector("animateTransform");
animNode.beginElement = jest.fn();
animNode.dispatchEvent(new Event("beginEvent"));
animNode.dispatchEvent(new Event("endEvent"));
fireEvent.click(svg);
expect(animNode.beginElement).toHaveBeenCalledTimes(1);
});
it("does not begin the animation when prefers-reduced-motion is set, but stays clickable", () => {
setMatchMediaMock({ reduceMotion: true });
const { container } = renderSpinSmooth();
const svg = container.querySelector("svg.spin-smooth");
const animNode = container.querySelector("animateTransform");
animNode.beginElement = jest.fn();
expect(() => fireEvent.click(svg)).not.toThrow();
expect(animNode.beginElement).not.toHaveBeenCalled();
});
it("applies .is-animating while the SMIL animation is running", () => {
const { container } = renderSpinSmooth();
const svg = container.querySelector("svg.spin-smooth");
const animNode = container.querySelector("animateTransform");
expect(svg.classList.contains("is-animating")).toBe(false);
act(() => {
animNode.dispatchEvent(new Event("beginEvent"));
});
expect(svg.classList.contains("is-animating")).toBe(true);
act(() => {
animNode.dispatchEvent(new Event("endEvent"));
});
expect(svg.classList.contains("is-animating")).toBe(false);
});
});
// @backward-compat { version 153 }
// Can be removed after Firefox 153 hits Release.
describe("", () => {
let originalMatchMedia;
beforeEach(() => {
originalMatchMedia = globalThis.matchMedia;
setMatchMediaMock();
});
afterEach(() => {
globalThis.matchMedia = originalMatchMedia;
});
function renderRotatingBall() {
return renderWithPrefs(, {
[PREF_LOGO_VARIATION]: "rotating-ball",
});
}
it("renders an aria-hidden SVG with the shared and variation classes", () => {
const { container } = renderRotatingBall();
const svg = container.querySelector("svg.rotating-ball");
expect(svg).toBeInTheDocument();
expect(svg.getAttribute("aria-hidden")).toBe("true");
expect(svg.classList.contains("logo-variation-small")).toBe(true);
});
it("renders an animateTransform with begin=indefinite (manual trigger)", () => {
const { container } = renderRotatingBall();
const anim = container.querySelector("animateTransform");
expect(anim).toBeInTheDocument();
expect(anim.getAttribute("begin")).toBe("indefinite");
expect(anim.getAttribute("dur")).toBe("2.9333s");
expect(anim.getAttribute("calcMode")).toBe("discrete");
});
it("calls beginElement() on the animation on click", () => {
const { container } = renderRotatingBall();
const svg = container.querySelector("svg.rotating-ball");
const animNode = container.querySelector("animateTransform");
animNode.beginElement = jest.fn();
fireEvent.click(svg);
expect(animNode.beginElement).toHaveBeenCalledTimes(1);
});
it("ignores clicks while the animation is running", () => {
const { container } = renderRotatingBall();
const svg = container.querySelector("svg.rotating-ball");
const animNode = container.querySelector("animateTransform");
animNode.beginElement = jest.fn();
animNode.dispatchEvent(new Event("beginEvent"));
fireEvent.click(svg);
expect(animNode.beginElement).not.toHaveBeenCalled();
});
it("allows replay after the animation finishes", () => {
const { container } = renderRotatingBall();
const svg = container.querySelector("svg.rotating-ball");
const animNode = container.querySelector("animateTransform");
animNode.beginElement = jest.fn();
animNode.dispatchEvent(new Event("beginEvent"));
animNode.dispatchEvent(new Event("endEvent"));
fireEvent.click(svg);
expect(animNode.beginElement).toHaveBeenCalledTimes(1);
});
it("does not begin the animation when prefers-reduced-motion is set, but stays clickable", () => {
setMatchMediaMock({ reduceMotion: true });
const { container } = renderRotatingBall();
const svg = container.querySelector("svg.rotating-ball");
const animNode = container.querySelector("animateTransform");
animNode.beginElement = jest.fn();
expect(() => fireEvent.click(svg)).not.toThrow();
expect(animNode.beginElement).not.toHaveBeenCalled();
});
it("applies .is-animating while the SMIL animation is running", () => {
const { container } = renderRotatingBall();
const svg = container.querySelector("svg.rotating-ball");
const animNode = container.querySelector("animateTransform");
expect(svg.classList.contains("is-animating")).toBe(false);
act(() => {
animNode.dispatchEvent(new Event("beginEvent"));
});
expect(svg.classList.contains("is-animating")).toBe(true);
act(() => {
animNode.dispatchEvent(new Event("endEvent"));
});
expect(svg.classList.contains("is-animating")).toBe(false);
});
});
// @backward-compat { version 153 }
// Can be removed after Firefox 153 hits Release.
describe("", () => {
let originalMatchMedia;
let originalDir;
beforeEach(() => {
originalMatchMedia = globalThis.matchMedia;
originalDir = document.dir;
setMatchMediaMock();
document.dir = "ltr";
});
afterEach(() => {
globalThis.matchMedia = originalMatchMedia;
document.dir = originalDir;
});
function renderFootballBounce() {
return renderWithPrefs(, {
[PREF_LOGO_VARIATION]: "football-bounce",
});
}
it("renders a 64x64 container wrapping an aria-hidden sprite SVG", () => {
const { container } = renderFootballBounce();
const wrapper = container.querySelector("div.football-bounce");
expect(wrapper).toBeInTheDocument();
expect(wrapper.classList.contains("logo-variation-small")).toBe(true);
const svg = wrapper.querySelector("svg.football-bounce__sprite");
expect(svg).toBeInTheDocument();
expect(svg.getAttribute("aria-hidden")).toBe("true");
});
it("renders an animateTransform with begin=indefinite and fill=freeze", () => {
const { container } = renderFootballBounce();
const anim = container.querySelector("animateTransform");
expect(anim).toBeInTheDocument();
expect(anim.getAttribute("begin")).toBe("indefinite");
expect(anim.getAttribute("dur")).toBe("3.752s");
expect(anim.getAttribute("calcMode")).toBe("discrete");
expect(anim.getAttribute("fill")).toBe("freeze");
});
it("calls beginElement() on the animation when the container is clicked", () => {
const { container } = renderFootballBounce();
const wrapper = container.querySelector("div.football-bounce");
const animNode = container.querySelector("animateTransform");
animNode.beginElement = jest.fn();
fireEvent.click(wrapper);
expect(animNode.beginElement).toHaveBeenCalledTimes(1);
});
it("ignores clicks while the animation is running", () => {
const { container } = renderFootballBounce();
const wrapper = container.querySelector("div.football-bounce");
const animNode = container.querySelector("animateTransform");
animNode.beginElement = jest.fn();
animNode.dispatchEvent(new Event("beginEvent"));
fireEvent.click(wrapper);
expect(animNode.beginElement).not.toHaveBeenCalled();
});
it("allows replay after the animation finishes", () => {
const { container } = renderFootballBounce();
const wrapper = container.querySelector("div.football-bounce");
const animNode = container.querySelector("animateTransform");
animNode.beginElement = jest.fn();
animNode.dispatchEvent(new Event("beginEvent"));
animNode.dispatchEvent(new Event("endEvent"));
fireEvent.click(wrapper);
expect(animNode.beginElement).toHaveBeenCalledTimes(1);
});
it("does not begin the animation when prefers-reduced-motion is set, but stays clickable", () => {
setMatchMediaMock({ reduceMotion: true });
const { container } = renderFootballBounce();
const wrapper = container.querySelector("div.football-bounce");
const animNode = container.querySelector("animateTransform");
animNode.beginElement = jest.fn();
expect(() => fireEvent.click(wrapper)).not.toThrow();
expect(animNode.beginElement).not.toHaveBeenCalled();
});
it("applies .is-animating on the container while the SMIL animation is running", () => {
const { container } = renderFootballBounce();
const wrapper = container.querySelector("div.football-bounce");
const animNode = container.querySelector("animateTransform");
expect(wrapper.classList.contains("is-animating")).toBe(false);
act(() => {
animNode.dispatchEvent(new Event("beginEvent"));
});
expect(wrapper.classList.contains("is-animating")).toBe(true);
act(() => {
animNode.dispatchEvent(new Event("endEvent"));
});
expect(wrapper.classList.contains("is-animating")).toBe(false);
});
it("falls back to SpinSmooth in RTL locales", () => {
document.dir = "rtl";
const { container } = renderWithPrefs(, {
[PREF_LOGO_VARIATION]: "football-bounce",
});
expect(container.querySelector(".football-bounce")).not.toBeInTheDocument();
expect(container.querySelector(".spin-smooth")).toBeInTheDocument();
});
});