/* 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 https://mozilla.org/MPL/2.0/. */ import { DEFAULT_PAGE_LAYOUT_VARIANT, PAGE_LAYOUT_VARIANTS, SIDE_BY_SIDE_PAGE_LAYOUTS, SPACES_PAGE_LAYOUTS, isSideBySideActive, isSideBySideAssigned, isSpacesActive, isSpacesAssigned, resolvePageLayoutVariant, resolvePopulatedSpaces, sideBySideBandClasses, spacesBandClasses, } from "common/PageLayoutVariants.mjs"; // The gates isSideBySideActive checks besides the variant itself. const STORIES_ON = { "feeds.section.topstories": true, "feeds.system.topstories": true, }; // The container being on is not enough: something has to actually be in it. const WIDGETS_ON = { "widgets.system.enabled": true, "widgets.enabled": true, "widgets.system.lists.enabled": true, "widgets.lists.enabled": true, }; const sideBySide = { "pageLayouts.variant": PAGE_LAYOUT_VARIANTS.SIDE_BY_SIDE_CONTENT_LEAD, ...STORIES_ON, ...WIDGETS_ON, }; describe("resolvePageLayoutVariant", () => { it("defaults to the full-width layout when nothing is set", () => { expect(resolvePageLayoutVariant({})).toBe(DEFAULT_PAGE_LAYOUT_VARIANT); expect(DEFAULT_PAGE_LAYOUT_VARIANT).toBe("nova-full-width"); }); it("returns the pref value", () => { expect( resolvePageLayoutVariant({ "pageLayouts.variant": PAGE_LAYOUT_VARIANTS.SIDE_BY_SIDE_WIDGETS_LEAD, }) ).toBe("side-by-side-widgets-lead"); }); it("lets trainhopConfig override a non-default pref value", () => { expect( resolvePageLayoutVariant({ "pageLayouts.variant": PAGE_LAYOUT_VARIANTS.SIDE_BY_SIDE_CONTENT_LEAD, trainhopConfig: { pageLayouts: { variant: PAGE_LAYOUT_VARIANTS.SIDE_BY_SIDE_WIDGETS_LEAD, }, }, }) ).toBe("side-by-side-widgets-lead"); }); it("falls back to the pref for a missing or non-string trainhop value", () => { const pref = { "pageLayouts.variant": PAGE_LAYOUT_VARIANTS.SIDE_BY_SIDE_CONTENT_LEAD, }; expect(resolvePageLayoutVariant({ ...pref, trainhopConfig: {} })).toBe( "side-by-side-content-lead" ); expect( resolvePageLayoutVariant({ ...pref, trainhopConfig: { pageLayouts: {} } }) ).toBe("side-by-side-content-lead"); expect( resolvePageLayoutVariant({ ...pref, trainhopConfig: { pageLayouts: { variant: true } }, }) ).toBe("side-by-side-content-lead"); expect( resolvePageLayoutVariant({ ...pref, trainhopConfig: { pageLayouts: { variant: "" } }, }) ).toBe("side-by-side-content-lead"); }); }); describe("isSideBySideAssigned", () => { it("is true for every side-by-side variant regardless of the sections", () => { expect(SIDE_BY_SIDE_PAGE_LAYOUTS).toHaveLength(4); for (const variant of SIDE_BY_SIDE_PAGE_LAYOUTS) { expect(isSideBySideAssigned({ "pageLayouts.variant": variant })).toBe( true ); } }); // This is the difference from isSideBySideActive: the panels follow the // assignment, so a lone section is still framed. it("stays true where isSideBySideActive is false", () => { const noWidgets = { ...sideBySide, "widgets.lists.enabled": false }; expect(isSideBySideAssigned(noWidgets)).toBe(true); expect(isSideBySideActive(noWidgets)).toBe(false); }); it("is false for the default layout", () => { expect(isSideBySideAssigned({})).toBe(false); expect( isSideBySideAssigned({ "pageLayouts.variant": "nova-full-width" }) ).toBe(false); }); it("follows a variant set through trainhopConfig", () => { expect( isSideBySideAssigned({ trainhopConfig: { pageLayouts: { variant: PAGE_LAYOUT_VARIANTS.SIDE_BY_SIDE_WIDGETS_LEAD, }, }, }) ).toBe(true); }); }); describe("isSideBySideActive", () => { it("is true for every side-by-side variant when stories and widgets are on", () => { for (const variant of SIDE_BY_SIDE_PAGE_LAYOUTS) { expect( isSideBySideActive({ ...sideBySide, "pageLayouts.variant": variant }) ).toBe(true); } }); it("is false for the default variant even with stories and widgets on", () => { expect( isSideBySideActive({ ...sideBySide, "pageLayouts.variant": undefined }) ).toBe(false); }); it("is false when either stories pref is off", () => { expect( isSideBySideActive({ ...sideBySide, "feeds.section.topstories": false }) ).toBe(false); expect( isSideBySideActive({ ...sideBySide, "feeds.system.topstories": false }) ).toBe(false); }); it("is false when the widgets container is not visible", () => { expect( isSideBySideActive({ ...sideBySide, "widgets.system.enabled": false }) ).toBe(false); }); it("is false when the container is on but every widget is hidden", () => { expect( isSideBySideActive({ ...sideBySide, "widgets.enabled": false }) ).toBe(false); expect( isSideBySideActive({ ...sideBySide, "widgets.lists.enabled": false }) ).toBe(false); }); // Small weather lives in the sidebar, not the band. it("is false when the only enabled widget is weather in the sidebar", () => { const weatherOnly = { ...sideBySide, "widgets.lists.enabled": false, "widgets.system.weather.enabled": true, "widgets.weather.enabled": true, "weather.display": "", "widgets.weather.size": "small", }; expect(isSideBySideActive(weatherOnly)).toBe(false); // ...but a larger weather widget stays in the content area and does count. expect( isSideBySideActive({ ...weatherOnly, "widgets.weather.size": "medium" }) ).toBe(true); }); it("is true when the variant comes from trainhopConfig", () => { expect( isSideBySideActive({ ...STORIES_ON, ...WIDGETS_ON, trainhopConfig: { pageLayouts: { variant: PAGE_LAYOUT_VARIANTS.SIDE_BY_SIDE_CONTENT_LEAD, }, }, }) ).toBe(true); }); // Goes through isWidgetsContainerVisible, so the keys the hand-rolled copies in // DiscoveryStreamBase.jsx and selectLayoutRender.mjs miss still count. it("is true when widgets are enabled only via trainhopConfig.widgetsSettings", () => { expect( isSideBySideActive({ ...sideBySide, "widgets.system.enabled": false, trainhopConfig: { widgetsSettings: { enabled: true } }, }) ).toBe(true); }); it("is true when widgets are enabled only via trainhopConfig.widgets", () => { expect( isSideBySideActive({ ...sideBySide, "widgets.system.enabled": false, trainhopConfig: { widgets: { enabled: true } }, }) ).toBe(true); }); }); describe("sideBySideBandClasses", () => { it("is empty outside the experiment", () => { expect(sideBySideBandClasses({})).toEqual([]); expect( sideBySideBandClasses({ "pageLayouts.variant": PAGE_LAYOUT_VARIANTS.NOVA_FULL_WIDTH, }) ).toEqual([]); expect( sideBySideBandClasses({ "pageLayouts.variant": "not-a-variant" }) ).toEqual([]); }); // The lead class alone, so nothing unlocks the fourth content card. it("gives the four-column variants just their lead class", () => { expect( sideBySideBandClasses({ "pageLayouts.variant": PAGE_LAYOUT_VARIANTS.SIDE_BY_SIDE_CONTENT_LEAD, }) ).toEqual(["side-by-side-content-lead"]); expect( sideBySideBandClasses({ "pageLayouts.variant": PAGE_LAYOUT_VARIANTS.SIDE_BY_SIDE_WIDGETS_LEAD, }) ).toEqual(["side-by-side-widgets-lead"]); }); // The lead class is shared with the four-column variant, so every existing // side-by-side rule keeps matching; side-by-side-five is the only difference. it("adds side-by-side-five for the five-column variants", () => { expect( sideBySideBandClasses({ "pageLayouts.variant": PAGE_LAYOUT_VARIANTS.SIDE_BY_SIDE_CONTENT_LEAD_FIVE, }) ).toEqual(["side-by-side-content-lead", "side-by-side-five"]); expect( sideBySideBandClasses({ "pageLayouts.variant": PAGE_LAYOUT_VARIANTS.SIDE_BY_SIDE_WIDGETS_LEAD_FIVE, }) ).toEqual(["side-by-side-widgets-lead", "side-by-side-five"]); }); // CSS matches classes per token, so a "-five" variant name would not be // matched by any .side-by-side-*-lead rule. It must never reach the class list. it("never emits a -five variant name as a class", () => { for (const variant of SIDE_BY_SIDE_PAGE_LAYOUTS.filter(v => v.endsWith("-five") )) { expect( sideBySideBandClasses({ "pageLayouts.variant": variant }) ).not.toContain(variant); } }); it("follows a variant set through trainhopConfig", () => { expect( sideBySideBandClasses({ trainhopConfig: { pageLayouts: { variant: PAGE_LAYOUT_VARIANTS.SIDE_BY_SIDE_WIDGETS_LEAD_FIVE, }, }, }) ).toEqual(["side-by-side-widgets-lead", "side-by-side-five"]); }); }); const spaces = { "nova.enabled": true, "pageLayouts.variant": PAGE_LAYOUT_VARIANTS.SPACES_BUTTONS_BOTTOM, ...STORIES_ON, ...WIDGETS_ON, }; describe("spacesBandClasses", () => { it("decomposes each variant into the family class plus a placement", () => { expect( spacesBandClasses({ "pageLayouts.variant": PAGE_LAYOUT_VARIANTS.SPACES_BUTTONS_TOP, }) ).toEqual(["spaces", "spaces-buttons-top"]); expect( spacesBandClasses({ "pageLayouts.variant": PAGE_LAYOUT_VARIANTS.SPACES_BUTTONS_BOTTOM, }) ).toEqual(["spaces", "spaces-buttons-bottom"]); }); it("returns nothing for the other layouts", () => { expect(spacesBandClasses({})).toEqual([]); expect( spacesBandClasses({ "pageLayouts.variant": PAGE_LAYOUT_VARIANTS.SIDE_BY_SIDE_CONTENT_LEAD, }) ).toEqual([]); }); }); describe("isSpacesAssigned", () => { it("is true for every spaces variant, whatever the content prefs", () => { for (const variant of SPACES_PAGE_LAYOUTS) { expect(isSpacesAssigned({ "pageLayouts.variant": variant })).toBe(true); } }); it("is false outside the spaces variants", () => { expect(isSpacesAssigned({})).toBe(false); expect( isSpacesAssigned({ "pageLayouts.variant": PAGE_LAYOUT_VARIANTS.SIDE_BY_SIDE_WIDGETS_LEAD, }) ).toBe(false); }); }); describe("resolvePopulatedSpaces", () => { it("lists only the spaces whose content is enabled, in tablist order", () => { expect(resolvePopulatedSpaces(spaces)).toEqual(["stories", "widgets"]); expect( resolvePopulatedSpaces({ ...spaces, "feeds.section.highlights": true, }) ).toEqual(["stories", "widgets", "activity"]); }); it("does not turn anything on", () => { expect(resolvePopulatedSpaces({ ...spaces, ...{} })).not.toContain( "activity" ); expect(resolvePopulatedSpaces({})).toEqual([]); }); }); describe("the experiment override", () => { // One shape for all three spaces: the experiment turns a space on unless its // opt-out pref says the user turned it off while enrolled. The user's own // pref is never written, so unenrolling restores their choice. const enrolled = { ...spaces, trainhopConfig: { stories: { enabled: true }, widgets: { enabled: true }, highlights: { enabled: true }, }, }; it("overrides a space the profile enrolled with turned off", () => { expect( resolvePopulatedSpaces({ ...enrolled, "feeds.section.topstories": false, "feeds.section.highlights": false, }) ).toEqual(["stories", "widgets", "activity"]); }); it("respects a space turned off while enrolled, for every space", () => { expect( resolvePopulatedSpaces({ ...enrolled, "feeds.section.topstories": false, "spaces.storiesOptOut": true, "widgets.enabled": false, "spaces.widgetsOptOut": true, "feeds.section.highlights": false, "spaces.activityOptOut": true, }) ).toEqual([]); }); it("does not override outside the experiment", () => { expect( resolvePopulatedSpaces({ ...enrolled, "pageLayouts.variant": PAGE_LAYOUT_VARIANTS.NOVA_FULL_WIDTH, "feeds.section.topstories": false, }) ).toEqual(["widgets"]); }); it("does not conjure content that is not there", () => { // Region and locale decide whether stories exist at all, and no override // should produce an empty panel. expect( resolvePopulatedSpaces({ ...enrolled, "feeds.system.topstories": false, }) ).not.toContain("stories"); }); }); describe("the widgets space gate", () => { it("is not a space when the user has switched widgets off", () => { expect( resolvePopulatedSpaces({ ...spaces, "widgets.enabled": false }) ).not.toContain("widgets"); }); it("is not a space when the container is on but no widget is enabled", () => { expect( resolvePopulatedSpaces({ ...spaces, "widgets.lists.enabled": false, }) ).not.toContain("widgets"); }); it("is not a space when weather is the only widget", () => { // Weather moves to the sidebar at its small size, so the row is empty. expect( resolvePopulatedSpaces({ ...spaces, "widgets.lists.enabled": false, "widgets.system.weather.enabled": true, "widgets.weather.enabled": true, "widgets.weather.size": "small", }) ).not.toContain("widgets"); }); it("is a space when a widget is actually showing", () => { expect(resolvePopulatedSpaces(spaces)).toContain("widgets"); }); }); describe("isSpacesActive", () => { it("is true with a spaces variant and somewhere to navigate to", () => { expect(isSpacesActive(spaces)).toBe(true); }); it("is false with only one populated space", () => { expect( isSpacesActive({ "pageLayouts.variant": PAGE_LAYOUT_VARIANTS.SPACES_BUTTONS_BOTTOM, ...STORIES_ON, }) ).toBe(false); }); it("is false outside the spaces variants", () => { expect( isSpacesActive({ ...spaces, "pageLayouts.variant": PAGE_LAYOUT_VARIANTS.NOVA_FULL_WIDTH, }) ).toBe(false); }); it("is false without Nova, whose stylesheet it depends on", () => { expect(isSpacesActive({ ...spaces, "nova.enabled": false })).toBe(false); }); });