/
vshmidt
/
streamlit
Обзор
Документация
Войти
/
vshmidt
/
streamlit
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
frontend/lib/src/theme/utils.test.ts
4 542 строки
143 KB
Lukas Masuch
Remove deprecated proto fields and unused theme creator code (#13842)
10 фев 2026, 01:13
Не верифицирован
10 фев 2026, 01:13
8722152
Код
Авторство
О чём код?
/** * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2026) * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { darken, lighten, transparentize } from "color2k" import { getLogger } from "loglevel" import { MockInstance } from "vitest" import { CustomThemeConfig, ICustomThemeConfig } from "@streamlit/protobuf" import { baseTheme, createAutoTheme, darkTheme, lightTheme, } from "~lib/theme/index" import { ThemeConfig } from "~lib/theme/types" import { LocalStore } from "~lib/util/storageUtils" import { hasLightBackgroundColor } from "./getColors" import { AUTO_THEME_NAME, bgColorToBaseString, computeSpacingStyle, createCustomThemes, createEmotionTheme, createSidebarTheme, createTheme, CUSTOM_THEME_AUTO_NAME, CUSTOM_THEME_DARK_NAME, CUSTOM_THEME_LIGHT_NAME, CUSTOM_THEME_NAME, getCachedThemeSelection, getDefaultTheme, getHostSpecifiedTheme, getHostSpecifiedThemeOnly, getSystemTheme, handleSectionInheritance, hasThemeSectionConfigs, isColor, isPresetTheme, mapCachedThemeSelectionToAvailableTheme, parseFont, removeCachedTheme, setCachedThemeSelection, sortThemeInputKeys, toThemeInput, } from "./utils" const matchMediaFillers = { onchange: null, addListener: vi.fn(), // deprecated removeListener: vi.fn(), // deprecated addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), } const LOG = getLogger("theme:utils") // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO: Replace 'any' with a more specific type. const windowLocationSearch = (search: string): any => ({ location: { search, }, }) // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO: Replace 'any' with a more specific type. const windowMatchMedia = (theme: "light" | "dark"): any => ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO: Replace 'any' with a more specific type. matchMedia: (query: any) => ({ matches: query === `(prefers-color-scheme: ${theme})`, media: query, ...matchMediaFillers, }), }) const mockWindow = (...overrides: object[]): MockInstance => { const localStorage = window.localStorage const windowSpy = vi.spyOn(window, "window", "get") windowSpy.mockImplementation(() => ({ localStorage, ...windowLocationSearch(""), ...windowMatchMedia("light"), ...Object.assign({}, ...overrides), })) return windowSpy } describe("Styling utils", () => { describe("computeSpacingStyle", () => { it("pulls correct theme values", () => { expect(computeSpacingStyle("sm md lg none", lightTheme.emotion)).toEqual( "0.5rem 0.75rem 1rem 0" ) expect(computeSpacingStyle("xs 0 px lg", lightTheme.emotion)).toEqual( "0.375rem 0 1px 1rem" ) }) }) // Note: Detailed shadow value tests are in getShadows.test.ts // These tests verify theme integration only describe("theme.shadows (integration)", () => { it("light and dark themes have shadows with the same property keys", () => { const lightKeys = Object.keys(lightTheme.emotion.shadows).sort() const darkKeys = Object.keys(darkTheme.emotion.shadows).sort() expect(lightKeys).toEqual(darkKeys) }) it("all shadow values are valid CSS box-shadow strings", () => { const themes = [lightTheme.emotion, darkTheme.emotion] themes.forEach(theme => { Object.values(theme.shadows).forEach(shadow => { expect(typeof shadow).toBe("string") expect(shadow.length).toBeGreaterThan(0) expect( shadow === "none" || shadow.includes("#") || shadow.includes("rgba(") ).toBe(true) }) }) }) it("custom themes compute focus ring shadows from custom primary color", () => { const customTheme = createTheme( "Custom", new CustomThemeConfig({ primaryColor: "#00ff00", // green }) ) const { shadows } = customTheme.emotion // Focus ring should use the custom primary color expect(shadows.focusRing).toContain("rgba(0, 255, 0") expect(shadows.focusRingOutline).toBe("0 0 0 1px #00ff00") }) }) }) describe("isPresetTheme", () => { it("returns true for the light, dark, and auto themes", () => { const presetThemes = [lightTheme, darkTheme, createAutoTheme()] presetThemes.forEach((themeConfig: ThemeConfig) => { expect(isPresetTheme(themeConfig)).toBe(true) }) }) it("returns false for custom themes", () => { const customTheme = createTheme( CUSTOM_THEME_NAME, new CustomThemeConfig({ primaryColor: "red", secondaryBackgroundColor: "blue", }) ) expect(isPresetTheme(customTheme)).toBe(false) }) }) describe("Cached theme helpers", () => { // NOTE: localStorage is weird, and calling .spyOn(window.localStorage, "setItem") // doesn't work. Accessing .__proto__ here isn't too bad of a crime since // it's test code. const breakLocalStorage = (): void => { vi // eslint-disable-next-line no-proto .spyOn(window.localStorage.__proto__, "setItem") .mockImplementation(() => { throw new Error("boom") }) } afterEach(() => { vi.restoreAllMocks() window.localStorage.clear() }) describe("getCachedThemeSelection", () => { it("returns null if localStorage is not available", () => { breakLocalStorage() // eslint-disable-next-line no-proto const getItemSpy = vi.spyOn(window.localStorage.__proto__, "getItem") expect(getCachedThemeSelection()).toBe(null) expect(getItemSpy).not.toHaveBeenCalled() }) it("returns null if no theme is set in localStorage", () => { expect(getCachedThemeSelection()).toBe(null) }) it("does not find cached themes with older versions, so returns null", () => { // Save a cachedTheme in LocalStorage with the key of a previous version. window.localStorage.setItem( LocalStore.CACHED_THEME_BASE_KEY, JSON.stringify({ name: darkTheme.name }) ) expect(getCachedThemeSelection()).toBe(null) }) it("returns cached theme selection if localStorage is available and one is set", () => { window.localStorage.setItem( LocalStore.ACTIVE_THEME, JSON.stringify("Dark") ) expect(getCachedThemeSelection()).toBe("Dark") }) }) describe("removeCachedTheme", () => { it("does nothing if localStorage is not available", () => { breakLocalStorage() const removeItemSpy = vi.spyOn( // eslint-disable-next-line no-proto window.localStorage.__proto__, "removeItem" ) removeCachedTheme() expect(removeItemSpy).not.toHaveBeenCalled() }) it("removes theme if localStorage", () => { const removeItemSpy = vi.spyOn( // eslint-disable-next-line no-proto window.localStorage.__proto__, "removeItem" ) removeCachedTheme() expect(removeItemSpy).toHaveBeenCalled() }) }) describe("setCachedThemeSelection", () => { it("does nothing if localStorage is not available", () => { breakLocalStorage() // eslint-disable-next-line no-proto const setItemSpy = vi.spyOn(window.localStorage.__proto__, "setItem") setCachedThemeSelection(darkTheme) // This looks a bit funny and is the way it is because the way we know // that localStorage is broken is that setItem throws an error at us. expect(setItemSpy).toHaveBeenCalledTimes(1) expect(setItemSpy).toHaveBeenCalledWith("testData", "testData") }) it("sets a preset theme selection if localStorage is available", () => { setCachedThemeSelection(darkTheme) const cachedTheme = JSON.parse( window.localStorage.getItem(LocalStore.ACTIVE_THEME) as string ) expect(cachedTheme).toBe("Dark") }) it("deletes cached themes with older versions", () => { window.localStorage.setItem("stActiveTheme", "I should get deleted :|") window.localStorage.setItem( LocalStore.CACHED_THEME_BASE_KEY, "I should get deleted too :|" ) setCachedThemeSelection(darkTheme) expect(window.localStorage.getItem("stActiveTheme")).toBe(null) expect( window.localStorage.getItem(LocalStore.CACHED_THEME_BASE_KEY) ).toBe(null) }) it("sets auto selection when auto theme is cached", () => { setCachedThemeSelection(createAutoTheme()) const cachedTheme = JSON.parse( window.localStorage.getItem(LocalStore.ACTIVE_THEME) as string ) expect(cachedTheme).toBe("System") }) }) describe("mapCachedThemeSelectionToAvailableTheme", () => { it("returns null when no cached theme selection provided", () => { const result = mapCachedThemeSelectionToAvailableTheme(null, [ lightTheme, ]) expect(result).toBe(null) }) it("maps light selection to Custom Theme Light when custom light/dark themes available", () => { const customLight = createTheme(CUSTOM_THEME_LIGHT_NAME, { primaryColor: "lightblue", }) const customDark = createTheme(CUSTOM_THEME_DARK_NAME, { primaryColor: "darkblue", }) const customAuto = createAutoTheme() const result = mapCachedThemeSelectionToAvailableTheme("Light", [ customLight, customDark, customAuto, ]) expect(result).toBe(customLight) expect(result?.name).toBe(CUSTOM_THEME_LIGHT_NAME) }) it("maps dark selection to Custom Theme Dark when custom light/dark themes available", () => { const customLight = createTheme(CUSTOM_THEME_LIGHT_NAME, { primaryColor: "lightblue", }) const customDark = createTheme(CUSTOM_THEME_DARK_NAME, { primaryColor: "darkblue", }) const customAuto = createAutoTheme() const result = mapCachedThemeSelectionToAvailableTheme("Dark", [ customLight, customDark, customAuto, ]) expect(result).toBe(customDark) expect(result?.name).toBe(CUSTOM_THEME_DARK_NAME) }) it("maps light selection to preset Light when custom themes removed", () => { const result = mapCachedThemeSelectionToAvailableTheme("Light", [ lightTheme, darkTheme, ]) expect(result).toBe(lightTheme) expect(result?.name).toBe("Light") }) it("maps dark selection to preset Dark when custom themes removed", () => { const result = mapCachedThemeSelectionToAvailableTheme("Dark", [ lightTheme, darkTheme, ]) expect(result).toBe(darkTheme) expect(result?.name).toBe("Dark") }) it("returns null when light selection with single custom theme", () => { const customTheme = createTheme(CUSTOM_THEME_NAME, { primaryColor: "blue", }) const result = mapCachedThemeSelectionToAvailableTheme("Light", [ customTheme, ]) // Don't map preset to single custom theme - let default logic handle it expect(result).toBe(null) }) it("returns null when no suitable match found", () => { const customSingle = createTheme(CUSTOM_THEME_NAME, { primaryColor: "blue", }) const result = mapCachedThemeSelectionToAvailableTheme("Light", [ customSingle, ]) expect(result).toBe(null) }) }) }) describe("createTheme", () => { it("returns a theme", () => { const customThemeConfig = new CustomThemeConfig({ primaryColor: "red", secondaryBackgroundColor: "blue", bodyFont: "serif", }) const customTheme = createTheme(CUSTOM_THEME_NAME, customThemeConfig) expect(customTheme.name).toBe(CUSTOM_THEME_NAME) expect(customTheme.emotion.colors.primary).toBe("red") expect(customTheme.emotion.colors.secondaryBg).toBe("blue") expect(customTheme.emotion.genericFonts.bodyFont).toBe( lightTheme.emotion.fonts.serif ) // If it is not provided, use the default expect(customTheme.emotion.colors.bgColor).toBe( lightTheme.emotion.colors.bgColor ) }) it("returns a theme based on a different theme", () => { const customThemeConfig = new CustomThemeConfig({ primaryColor: "red", secondaryBackgroundColor: "blue", bodyFont: "serif", }) const customTheme = createTheme( CUSTOM_THEME_NAME, customThemeConfig, darkTheme, // inSidebar true ) expect(customTheme.name).toBe(CUSTOM_THEME_NAME) expect(customTheme.emotion.colors.primary).toBe("red") expect(customTheme.emotion.colors.secondaryBg).toBe("blue") expect(customTheme.emotion.genericFonts.bodyFont).toBe( darkTheme.emotion.fonts.serif ) // If it is not provided, use the default expect(customTheme.emotion.colors.bgColor).toBe( darkTheme.emotion.colors.bgColor ) expect(customTheme.emotion.inSidebar).toBe(true) expect(darkTheme.emotion.inSidebar).toBe(false) }) it("handles hex values without #", () => { const customThemeConfig = new CustomThemeConfig({ primaryColor: "eee", secondaryBackgroundColor: "fc9231", bodyFont: "serif", }) const customTheme = createTheme( CUSTOM_THEME_NAME, customThemeConfig, darkTheme ) expect(customTheme.name).toBe(CUSTOM_THEME_NAME) expect(customTheme.emotion.colors.primary).toBe("#eee") expect(customTheme.emotion.colors.secondaryBg).toBe("#fc9231") expect(customTheme.emotion.genericFonts.bodyFont).toBe( customTheme.emotion.fonts.serif ) // If it is not provided, use the default expect(customTheme.emotion.colors.bgColor).toBe( darkTheme.emotion.colors.bgColor ) }) it("sets unspecified theme options using the given BaseTheme", () => { const customTheme = createTheme( CUSTOM_THEME_NAME, new CustomThemeConfig({ base: CustomThemeConfig.BaseTheme.DARK, primaryColor: "blue", }) ) expect(customTheme.emotion.colors.bgColor).toBe( darkTheme.emotion.colors.bgColor ) expect(customTheme.emotion.colors.primary).toBe("blue") // Auxiliary colors should be those of the Dark theme. expect(customTheme.emotion.colors.yellowTextColor).toBe( darkTheme.emotion.colors.yellowTextColor ) }) it("sets auxiliary colors based on backgroundColor over the BaseTheme", () => { const customTheme = createTheme( CUSTOM_THEME_NAME, new CustomThemeConfig({ backgroundColor: "black", base: CustomThemeConfig.BaseTheme.LIGHT, }) ) expect(customTheme.emotion.colors.bgColor).toBe("black") // Auxiliary colors should be picked to be ones that work well with the // black background even though the user set the base theme to light. expect(customTheme.emotion.colors.yellowTextColor).toBe( darkTheme.emotion.colors.yellowTextColor ) // Theme options should be inherited from the light theme as defined by the // user. expect(customTheme.emotion.colors.secondaryBg).toBe( lightTheme.emotion.colors.secondaryBg ) }) }) describe("getSystemTheme", () => { let windowSpy: MockInstance afterEach(() => { windowSpy.mockRestore() window.localStorage.clear() }) it("returns lightTheme when matchMedia does *not* match dark", () => { windowSpy = mockWindow() expect(getSystemTheme().name).toBe("Light") }) it("returns darkTheme when matchMedia does match dark", () => { windowSpy = mockWindow(windowMatchMedia("dark")) expect(getSystemTheme().name).toBe("Dark") }) }) describe("getHostSpecifiedTheme", () => { let windowSpy: MockInstance afterEach(() => { windowSpy.mockRestore() window.localStorage.clear() }) it("sets default to the auto theme when there is no theme preference", () => { windowSpy = mockWindow() const defaultTheme = getHostSpecifiedTheme() expect(defaultTheme.name).toBe(AUTO_THEME_NAME) // Also verify that the theme is our lightTheme. expect(defaultTheme.emotion.colors).toEqual(lightTheme.emotion.colors) }) it("sets the auto theme correctly when the OS preference is dark", () => { mockWindow(windowSpy, windowMatchMedia("dark")) const defaultTheme = getHostSpecifiedTheme() expect(defaultTheme.name).toBe(AUTO_THEME_NAME) expect(defaultTheme.emotion.colors).toEqual(darkTheme.emotion.colors) }) it("sets default to the light theme when an embed query parameter is set", () => { windowSpy = mockWindow( windowLocationSearch("?embed=true&embed_options=light_theme") ) const defaultTheme = getHostSpecifiedTheme() expect(defaultTheme.name).toBe("Light") // Also verify that the theme is our lightTheme. expect(defaultTheme.emotion.colors).toEqual(lightTheme.emotion.colors) }) it("sets default to the dark theme when an embed query parameter is set", () => { windowSpy = mockWindow( windowLocationSearch("?embed=true&embed_options=dark_theme") ) const defaultTheme = getHostSpecifiedTheme() expect(defaultTheme.name).toBe("Dark") // Also verify that the theme is our darkTheme. expect(defaultTheme.emotion.colors).toEqual(darkTheme.emotion.colors) }) it("respects embed query parameter is set over system theme", () => { windowSpy = mockWindow( windowMatchMedia("dark"), windowLocationSearch("?embed=true&embed_options=light_theme") ) const defaultTheme = getHostSpecifiedTheme() expect(defaultTheme.name).toBe("Light") // Also verify that the theme is our lightTheme. expect(defaultTheme.emotion.colors).toEqual(lightTheme.emotion.colors) }) }) describe("getHostSpecifiedThemeOnly", () => { let windowSpy: MockInstance afterEach(() => { windowSpy.mockRestore() window.localStorage.clear() }) it("returns null when there is no theme in query params", () => { windowSpy = mockWindow() const theme = getHostSpecifiedThemeOnly() expect(theme).toBeNull() }) it("returns light theme when embed_options=light_theme", () => { windowSpy = mockWindow( windowLocationSearch("?embed=true&embed_options=light_theme") ) const theme = getHostSpecifiedThemeOnly() expect(theme).not.toBeNull() expect(theme?.name).toBe("Light") expect(theme?.emotion.colors).toEqual(lightTheme.emotion.colors) }) it("returns dark theme when embed_options=dark_theme", () => { windowSpy = mockWindow( windowLocationSearch("?embed=true&embed_options=dark_theme") ) const theme = getHostSpecifiedThemeOnly() expect(theme).not.toBeNull() expect(theme?.name).toBe("Dark") expect(theme?.emotion.colors).toEqual(darkTheme.emotion.colors) }) it("ignores system theme preference when no query params", () => { windowSpy = mockWindow(windowMatchMedia("dark")) const theme = getHostSpecifiedThemeOnly() // Should return null, NOT the dark theme based on system preference expect(theme).toBeNull() }) }) describe("getDefaultTheme", () => { let windowSpy: MockInstance afterEach(() => { windowSpy.mockRestore() window.localStorage.clear() }) it("sets default to the auto theme when there is no cached theme", () => { windowSpy = mockWindow() const defaultTheme = getDefaultTheme() expect(defaultTheme.name).toBe(AUTO_THEME_NAME) // Also verify that the theme is our lightTheme. expect(defaultTheme.emotion.colors).toEqual(lightTheme.emotion.colors) }) it("sets the auto theme correctly when the OS preference is dark", () => { mockWindow(windowSpy, windowMatchMedia("dark")) const defaultTheme = getDefaultTheme() expect(defaultTheme.name).toBe(AUTO_THEME_NAME) expect(defaultTheme.emotion.colors).toEqual(darkTheme.emotion.colors) }) it("sets the default to the user preference when one is set", () => { windowSpy = mockWindow() setCachedThemeSelection(darkTheme) const defaultTheme = getDefaultTheme() expect(defaultTheme.name).toBe("Dark") expect(defaultTheme.emotion.colors).toEqual(darkTheme.emotion.colors) }) it("prioritizes embed query parameter over cached preference", () => { windowSpy = mockWindow( windowLocationSearch("?embed=true&embed_options=light_theme") ) setCachedThemeSelection(darkTheme) const defaultTheme = getDefaultTheme() expect(defaultTheme.name).toBe("Light") expect(defaultTheme.emotion.colors).toEqual(lightTheme.emotion.colors) }) it("sets default to the light theme when an embed query parameter is set", () => { windowSpy = mockWindow( windowLocationSearch("?embed=true&embed_options=light_theme") ) const defaultTheme = getDefaultTheme() expect(defaultTheme.name).toBe("Light") // Also verify that the theme is our lightTheme. expect(defaultTheme.emotion.colors).toEqual(lightTheme.emotion.colors) }) it("sets default to the dark theme when an embed query parameter is set", () => { windowSpy = mockWindow( windowLocationSearch("?embed=true&embed_options=dark_theme") ) const defaultTheme = getDefaultTheme() expect(defaultTheme.name).toBe("Dark") // Also verify that the theme is our darkTheme. expect(defaultTheme.emotion.colors).toEqual(darkTheme.emotion.colors) }) it("respects embed query parameter is set over system theme", () => { windowSpy = mockWindow( windowMatchMedia("dark"), windowLocationSearch("?embed=true&embed_options=light_theme") ) const defaultTheme = getDefaultTheme() expect(defaultTheme.name).toBe("Light") // Also verify that the theme is our lightTheme. expect(defaultTheme.emotion.colors).toEqual(lightTheme.emotion.colors) }) it("uses the auto theme when auto selection is cached", () => { windowSpy = mockWindow() setCachedThemeSelection(createAutoTheme()) const defaultTheme = getDefaultTheme() expect(defaultTheme.name).toBe(AUTO_THEME_NAME) }) }) describe("isColor", () => { // https://www.w3schools.com/cssref/css_colors_legal.asp it("works with valid colors", () => { expect(isColor("#fff")).toBe(true) expect(isColor("#ffffff")).toBe(true) expect(isColor("#ffffff0")).toBe(true) expect(isColor("#000")).toBe(true) expect(isColor("#000000")).toBe(true) expect(isColor("#fafafa")).toBe(true) expect(isColor("red")).toBe(true) expect(isColor("coral")).toBe(true) expect(isColor("transparent")).toBe(true) expect(isColor("rgb(0,0,0)")).toBe(true) expect(isColor("rgb(-1, 0, -255)")).toBe(true) expect(isColor("rgba(0,0,0,.5)")).toBe(true) expect(isColor("hsl(120,50%,40%)")).toBe(true) expect(isColor("hsl(120,50%,40%, .4)")).toBe(true) expect(isColor("currentColor")).toBe(true) }) it("works with invalid colors", () => { expect(isColor("fff")).toBe(false) expect(isColor("cookies are delicious")).toBe(false) expect(isColor("")).toBe(false) expect(isColor("hsl(120,50,40)")).toBe(false) }) }) describe("createEmotionTheme", () => { afterEach(() => { vi.clearAllMocks() }) it("sets to light when matchMedia does not match dark", () => { const themeInput: Partial<CustomThemeConfig> = { headingFont: "serif", bodyFont: "monospace", codeFont: "monospace", primaryColor: "red", backgroundColor: "pink", secondaryBackgroundColor: "blue", textColor: "orange", } const theme = createEmotionTheme(themeInput) expect(theme.colors.primary).toBe("red") expect(theme.colors.bgColor).toBe("pink") expect(theme.colors.secondaryBg).toBe("blue") expect(theme.colors.bodyText).toBe("orange") expect(theme.genericFonts.headingFont).toBe(theme.fonts.serif) expect(theme.genericFonts.bodyFont).toBe(theme.fonts.monospace) expect(theme.genericFonts.codeFont).toBe(theme.fonts.monospace) }) it("defaults to base if missing value", () => { const themeInput: Partial<CustomThemeConfig> = { primaryColor: "red", } const theme = createEmotionTheme(themeInput) expect(theme.colors.primary).toBe("red") expect(theme.colors.bgColor).toBe(baseTheme.emotion.colors.bgColor) expect(theme.colors.secondaryBg).toBe(baseTheme.emotion.colors.secondaryBg) expect(theme.colors.bodyText).toBe(baseTheme.emotion.colors.bodyText) expect(theme.genericFonts.headingFont).toBe( baseTheme.emotion.genericFonts.headingFont ) expect(theme.genericFonts.bodyFont).toBe( baseTheme.emotion.genericFonts.bodyFont ) expect(theme.genericFonts.codeFont).toBe( baseTheme.emotion.genericFonts.codeFont ) }) // == Theme border/underline properties == it("showSidebarBorder config is set to false by default", () => { const theme = createEmotionTheme({}) expect(theme.showSidebarBorder).toBe(false) }) it("sets the showSidebarBorder config to true if showSidebarBorder=true", () => { const theme = createEmotionTheme({ showSidebarBorder: true }) expect(theme.showSidebarBorder).toBe(true) }) it("linkUnderline config is set to true by default", () => { const theme = createEmotionTheme({}) expect(theme.linkUnderline).toBe(true) }) it("sets the linkUnderline config to false if linkUnderline=false", () => { const theme = createEmotionTheme({ linkUnderline: false }) expect(theme.linkUnderline).toBe(false) }) // == Theme color properties == // Handled in newGenericColors it.each([ // Test valid color values ["red", "orange", "blue", "pink", "purple"], ["#ff0000", "#ffa500", "#0000ff", "#ffc0cb", "#800080"], [ "rgb(255, 0, 0)", "rgb(255, 165, 0)", "rgb(0, 0, 255)", "rgb(255, 192, 192)", "rgb(128, 0, 128)", ], ])( "correctly handles setting of basic color configs '%s'", (primary, bodyText, secondaryBg, bgColor) => { const themeInput: Partial<CustomThemeConfig> = { primaryColor: primary, textColor: bodyText, secondaryBackgroundColor: secondaryBg, backgroundColor: bgColor, } const theme = createEmotionTheme(themeInput) expect(theme.colors.primary).toBe(primary) expect(theme.colors.bodyText).toBe(bodyText) expect(theme.colors.secondaryBg).toBe(secondaryBg) expect(theme.colors.bgColor).toBe(bgColor) } ) describe("logs a warning and falls back to default for invalid color configs", () => { it("handles invalid primaryColor", () => { const logWarningSpy = vi.spyOn(LOG, "warn") const theme = createEmotionTheme({ primaryColor: "invalid", textColor: "orange", secondaryBackgroundColor: "blue", backgroundColor: "pink", borderColor: "purple", }) expect(logWarningSpy).toHaveBeenCalledWith( 'Invalid color passed for primaryColor in theme: "invalid"' ) expect(theme.colors.primary).toBe(baseTheme.emotion.colors.primary) expect(theme.colors.bodyText).toBe("orange") expect(theme.colors.secondaryBg).toBe("blue") expect(theme.colors.bgColor).toBe("pink") expect(theme.colors.borderColor).toBe("purple") }) it("handles invalid textColor", () => { const logWarningSpy = vi.spyOn(LOG, "warn") const theme = createEmotionTheme({ primaryColor: "red", textColor: "invalid", secondaryBackgroundColor: "blue", backgroundColor: "pink", borderColor: "purple", }) expect(logWarningSpy).toHaveBeenCalledWith( 'Invalid color passed for textColor in theme: "invalid"' ) expect(theme.colors.primary).toBe("red") expect(theme.colors.bodyText).toBe(baseTheme.emotion.colors.bodyText) expect(theme.colors.secondaryBg).toBe("blue") expect(theme.colors.bgColor).toBe("pink") expect(theme.colors.borderColor).toBe("purple") }) it("handles invalid secondaryBackgroundColor", () => { const logWarningSpy = vi.spyOn(LOG, "warn") const theme = createEmotionTheme({ primaryColor: "red", textColor: "orange", secondaryBackgroundColor: "invalid", backgroundColor: "pink", borderColor: "purple", }) expect(logWarningSpy).toHaveBeenCalledWith( 'Invalid color passed for secondaryBackgroundColor in theme: "invalid"' ) expect(theme.colors.primary).toBe("red") expect(theme.colors.bodyText).toBe("orange") expect(theme.colors.secondaryBg).toBe( baseTheme.emotion.colors.secondaryBg ) expect(theme.colors.bgColor).toBe("pink") expect(theme.colors.borderColor).toBe("purple") }) it("handles invalid backgroundColor", () => { const logWarningSpy = vi.spyOn(LOG, "warn") const theme = createEmotionTheme({ primaryColor: "red", textColor: "orange", secondaryBackgroundColor: "blue", backgroundColor: "invalid", borderColor: "purple", }) expect(logWarningSpy).toHaveBeenCalledWith( 'Invalid color passed for backgroundColor in theme: "invalid"' ) expect(theme.colors.primary).toBe("red") expect(theme.colors.bodyText).toBe("orange") expect(theme.colors.secondaryBg).toBe("blue") expect(theme.colors.bgColor).toBe(baseTheme.emotion.colors.bgColor) expect(theme.colors.borderColor).toBe("purple") }) it("handles invalid borderColor", () => { const logWarningSpy = vi.spyOn(LOG, "warn") const theme = createEmotionTheme({ primaryColor: "red", textColor: "orange", secondaryBackgroundColor: "blue", backgroundColor: "pink", borderColor: "invalid", }) expect(logWarningSpy).toHaveBeenCalledWith( 'Invalid color passed for borderColor in theme: "invalid"' ) expect(theme.colors.primary).toBe("red") expect(theme.colors.bodyText).toBe("orange") expect(theme.colors.secondaryBg).toBe("blue") expect(theme.colors.bgColor).toBe("pink") expect(theme.colors.borderColor).toBe(theme.colors.fadedText10) }) }) // Main theme colors it.each([ // Test valid main theme color values ["#ff0000", "#ff0000"], ["rgb(255, 0, 0)", "rgb(255, 0, 0)"], ["rgba(196, 77, 86, 1)", "rgba(196, 77, 86, 1)"], ["red", "red"], ["ff0000", "#ff0000"], // Handles no leading # ])("uses configured main theme colors if set", (color, expectedColor) => { const themeInput: Partial<CustomThemeConfig> = { redColor: color, } const theme = createEmotionTheme(themeInput, lightTheme) expect(theme.colors.redColor).toBe(expectedColor) expect(theme.colors.orangeColor).toBe(theme.colors.orange70) expect(theme.colors.yellowColor).toBe(theme.colors.yellow80) expect(theme.colors.blueColor).toBe(theme.colors.blue70) expect(theme.colors.greenColor).toBe(theme.colors.green70) expect(theme.colors.violetColor).toBe(theme.colors.purple70) expect(theme.colors.grayColor).toBe(theme.colors.gray60) }) it.each([ // Test invalid main theme color values ["invalid"], ["rgb(255, 0, 0"], // Missing closing parenthesis ["corgi"], // Invalid color name ["#G00000"], // Invalid hex code ])( "logs a warning and falls back to default for invalid main theme colors '%s'", color => { const logWarningSpy = vi.spyOn(LOG, "warn") const themeInput: Partial<CustomThemeConfig> = { orangeColor: color, } const theme = createEmotionTheme(themeInput, lightTheme) expect(logWarningSpy).toHaveBeenCalledWith( `Invalid color passed for orangeColor in theme: "${color}"` ) // Falls back to default orange expect(theme.colors.orangeColor).toBe(theme.colors.orange70) // All others use defaults expect(theme.colors.redColor).toBe(theme.colors.red70) expect(theme.colors.yellowColor).toBe(theme.colors.yellow80) expect(theme.colors.blueColor).toBe(theme.colors.blue70) expect(theme.colors.greenColor).toBe(theme.colors.green70) expect(theme.colors.violetColor).toBe(theme.colors.purple70) expect(theme.colors.grayColor).toBe(theme.colors.gray60) } ) it("falls back to default main theme colors if not set", () => { const themeInput: Partial<CustomThemeConfig> = { base: CustomThemeConfig.BaseTheme.LIGHT, } const theme = createEmotionTheme(themeInput, lightTheme) expect(theme.colors.redColor).toBe(theme.colors.red70) expect(theme.colors.orangeColor).toBe(theme.colors.orange70) expect(theme.colors.yellowColor).toBe(theme.colors.yellow80) expect(theme.colors.blueColor).toBe(theme.colors.blue70) expect(theme.colors.greenColor).toBe(theme.colors.green70) expect(theme.colors.violetColor).toBe(theme.colors.purple70) expect(theme.colors.grayColor).toBe(theme.colors.gray60) }) it("default main theme colors are set correctly for dark theme", () => { const themeInput: Partial<CustomThemeConfig> = { base: CustomThemeConfig.BaseTheme.DARK, } const theme = createEmotionTheme(themeInput, darkTheme) expect(theme.colors.redColor).toBe(theme.colors.red80) expect(theme.colors.orangeColor).toBe(theme.colors.orange80) expect(theme.colors.yellowColor).toBe(theme.colors.yellow70) expect(theme.colors.blueColor).toBe(theme.colors.blue80) expect(theme.colors.greenColor).toBe(theme.colors.green80) expect(theme.colors.violetColor).toBe(theme.colors.purple70) expect(theme.colors.grayColor).toBe(theme.colors.gray80) }) // Conditional Overrides - Colors it("sets the codeBackgroundColor if configured", () => { const themeInput: Partial<CustomThemeConfig> = { codeBackgroundColor: "pink", } const theme = createEmotionTheme(themeInput) expect(theme.colors.codeBackgroundColor).toBe("pink") }) it("uses default codeBackgroundColor if not configured", () => { const theme = createEmotionTheme({}) expect(theme.colors.codeBackgroundColor).toBe(theme.colors.bgMix) }) it("sets the dataframeHeaderBackgroundColor if configured", () => { const themeInput: Partial<CustomThemeConfig> = { dataframeHeaderBackgroundColor: "#FFC0CB", } const theme = createEmotionTheme(themeInput) expect(theme.colors.dataframeHeaderBackgroundColor).toBe("#FFC0CB") }) it("uses default dataframeHeaderBackgroundColor if not configured", () => { const theme = createEmotionTheme({}) expect(theme.colors.dataframeHeaderBackgroundColor).toBe( theme.colors.bgMix ) }) it("sets the borderColor properties based on borderColor config", () => { const themeInput: Partial<CustomThemeConfig> = { borderColor: "blue", // Note no specified dataframeBorderColor } const theme = createEmotionTheme(themeInput) expect(theme.colors.borderColor).toBe("blue") expect(theme.colors.borderColorLight).toBe(transparentize("blue", 0.55)) // Sets the dataframeBorderColor based on borderColor if dataframeBorderColor // not configured expect(theme.colors.dataframeBorderColor).toBe( theme.colors.borderColorLight ) }) it("sets the dataframeBorderColor if configured", () => { const themeInput: Partial<CustomThemeConfig> = { borderColor: "red", dataframeBorderColor: "green", } const theme = createEmotionTheme(themeInput) expect(theme.colors.borderColor).toBe("red") expect(theme.colors.dataframeBorderColor).toBe("green") }) it("handles showWidgetBorder config", () => { const themeInput: Partial<CustomThemeConfig> = { showWidgetBorder: true, } const theme = createEmotionTheme(themeInput) expect(theme.colors.widgetBorderColor).toBe(theme.colors.borderColor) }) // Background theme colors it.each([ ["#ff0000", "#ff0000"], ["rgb(255, 0, 0)", "rgb(255, 0, 0)"], ["rgba(196, 77, 86, 0.1)", "rgba(196, 77, 86, 0.1)"], ["red", "red"], ["ff0000", "#ff0000"], // Handles no leading # ])( "uses configured background theme colors if set", (color, expectedColor) => { const themeInput: Partial<CustomThemeConfig> = { redBackgroundColor: color, } const theme = createEmotionTheme(themeInput, lightTheme) expect(theme.colors.redBackgroundColor).toBe(expectedColor) expect(theme.colors.orangeBackgroundColor).toBe( lightTheme.emotion.colors.orangeBackgroundColor ) expect(theme.colors.yellowBackgroundColor).toBe( lightTheme.emotion.colors.yellowBackgroundColor ) expect(theme.colors.blueBackgroundColor).toBe( lightTheme.emotion.colors.blueBackgroundColor ) expect(theme.colors.greenBackgroundColor).toBe( lightTheme.emotion.colors.greenBackgroundColor ) expect(theme.colors.violetBackgroundColor).toBe( lightTheme.emotion.colors.violetBackgroundColor ) expect(theme.colors.grayBackgroundColor).toBe( lightTheme.emotion.colors.grayBackgroundColor ) } ) it.each([ // Test invalid background color values ["invalid"], ["rgb(255, 0, 0"], // Missing closing parenthesis ["corgi"], // Invalid color name ["#G00000"], // Invalid hex code ])( "logs a warning and falls back to default for invalid background theme colors '%s'", color => { const logWarningSpy = vi.spyOn(LOG, "warn") const themeInput: Partial<CustomThemeConfig> = { orangeBackgroundColor: color, } const theme = createEmotionTheme(themeInput, lightTheme) expect(logWarningSpy).toHaveBeenCalledWith( `Invalid color passed for orangeBackgroundColor in theme: "${color}"` ) // Falls back to default orange background expect(theme.colors.orangeBackgroundColor).toBe( lightTheme.emotion.colors.orangeBackgroundColor ) // All others use defaults expect(theme.colors.redBackgroundColor).toBe( lightTheme.emotion.colors.redBackgroundColor ) expect(theme.colors.yellowBackgroundColor).toBe( lightTheme.emotion.colors.yellowBackgroundColor ) expect(theme.colors.blueBackgroundColor).toBe( lightTheme.emotion.colors.blueBackgroundColor ) expect(theme.colors.greenBackgroundColor).toBe( lightTheme.emotion.colors.greenBackgroundColor ) expect(theme.colors.violetBackgroundColor).toBe( lightTheme.emotion.colors.violetBackgroundColor ) expect(theme.colors.grayBackgroundColor).toBe( lightTheme.emotion.colors.grayBackgroundColor ) } ) it("defaults background colors are set correctly for light theme", () => { const themeInput: Partial<CustomThemeConfig> = { base: CustomThemeConfig.BaseTheme.LIGHT, } const theme = createEmotionTheme(themeInput, lightTheme) expect(theme.colors.redBackgroundColor).toBe( transparentize(theme.colors.red80, 0.9) ) expect(theme.colors.orangeBackgroundColor).toBe( transparentize(theme.colors.orange70, 0.9) ) expect(theme.colors.yellowBackgroundColor).toBe( transparentize(theme.colors.yellow65, 0.9) ) expect(theme.colors.blueBackgroundColor).toBe( transparentize(theme.colors.blue65, 0.9) ) expect(theme.colors.greenBackgroundColor).toBe( transparentize(theme.colors.green70, 0.9) ) expect(theme.colors.violetBackgroundColor).toBe( transparentize(theme.colors.purple60, 0.9) ) expect(theme.colors.grayBackgroundColor).toBe( transparentize(theme.colors.gray85, 0.9) ) }) it("default background colors are set correctly for dark theme", () => { const themeInput: Partial<CustomThemeConfig> = { base: CustomThemeConfig.BaseTheme.DARK, } const theme = createEmotionTheme(themeInput, darkTheme) expect(theme.colors.redBackgroundColor).toBe( transparentize(theme.colors.red60, 0.8) ) expect(theme.colors.orangeBackgroundColor).toBe( transparentize(theme.colors.orange80, 0.8) ) expect(theme.colors.yellowBackgroundColor).toBe( transparentize(theme.colors.yellow65, 0.8) ) expect(theme.colors.blueBackgroundColor).toBe( transparentize(theme.colors.blue60, 0.8) ) expect(theme.colors.greenBackgroundColor).toBe( transparentize(theme.colors.green60, 0.8) ) expect(theme.colors.violetBackgroundColor).toBe( transparentize(theme.colors.purple60, 0.8) ) expect(theme.colors.grayBackgroundColor).toBe( transparentize(theme.colors.gray70, 0.8) ) }) it("derives background colors from main colors with correct opacity for light theme", () => { const themeInput: Partial<CustomThemeConfig> = { redColor: "#ff0000", blueColor: "#0000ff", backgroundColor: "#ffffff", // Light theme } const theme = createEmotionTheme(themeInput, lightTheme) // Should derive red background from provided red color with 10% opacity (light theme) expect(theme.colors.redBackgroundColor).toBe("rgba(255, 0, 0, 0.1)") // Should derive blue background from provided blue color with 10% opacity (light theme) expect(theme.colors.blueBackgroundColor).toBe("rgba(0, 0, 255, 0.1)") // Should use default for colors not provided expect(theme.colors.orangeBackgroundColor).toBe( lightTheme.emotion.colors.orangeBackgroundColor ) }) it("derives background colors from main colors with correct opacity for dark theme", () => { const themeInput: Partial<CustomThemeConfig> = { redColor: "#ff0000", greenColor: "#00ff00", backgroundColor: "#000000", // Dark theme } const theme = createEmotionTheme(themeInput, darkTheme) // Should derive colors with 20% opacity for dark theme expect(theme.colors.redBackgroundColor).toBe("rgba(255, 0, 0, 0.2)") expect(theme.colors.greenBackgroundColor).toBe("rgba(0, 255, 0, 0.2)") // Should use default for colors not provided expect(theme.colors.blueBackgroundColor).toBe( darkTheme.emotion.colors.blueBackgroundColor ) }) it("uses explicit background color when both main color and background color are provided", () => { const themeInput: Partial<CustomThemeConfig> = { redColor: "#ff0000", redBackgroundColor: "#ffcccc", // Explicitly provided backgroundColor: "#ffffff", } const theme = createEmotionTheme(themeInput, lightTheme) // Should use explicitly provided background color, not derived expect(theme.colors.redBackgroundColor).toBe("#ffcccc") }) it("handles mixed explicit and derived background colors correctly", () => { const themeInput: Partial<CustomThemeConfig> = { // Explicit background colors redBackgroundColor: "#ffe6e6", blueBackgroundColor: "#e6f3ff", // Main colors that should derive backgrounds greenColor: "#00ff00", violetColor: "#8b00ff", // No yellow/orange/gray configs - should use defaults backgroundColor: "#ffffff", // Light theme } const theme = createEmotionTheme(themeInput, lightTheme) // Should use explicit backgrounds expect(theme.colors.redBackgroundColor).toBe("#ffe6e6") expect(theme.colors.blueBackgroundColor).toBe("#e6f3ff") // Should derive from main colors expect(theme.colors.greenBackgroundColor).toBe("rgba(0, 255, 0, 0.1)") expect(theme.colors.violetBackgroundColor).toBe("rgba(139, 0, 255, 0.1)") // Should use defaults expect(theme.colors.orangeBackgroundColor).toBe( lightTheme.emotion.colors.orangeBackgroundColor ) expect(theme.colors.yellowBackgroundColor).toBe( lightTheme.emotion.colors.yellowBackgroundColor ) expect(theme.colors.grayBackgroundColor).toBe( lightTheme.emotion.colors.grayBackgroundColor ) }) it("falls back to default when main color is invalid and no explicit background provided", () => { const logWarningSpy = vi.spyOn(LOG, "warn") const themeInput: Partial<CustomThemeConfig> = { redColor: "invalid-color", // Invalid main color blueColor: "#0000ff", // Valid main color backgroundColor: "#ffffff", } const theme = createEmotionTheme(themeInput, lightTheme) expect(logWarningSpy).toHaveBeenCalledWith( 'Invalid color passed for redColor in theme: "invalid-color"' ) // Should use default for red (main color invalid) expect(theme.colors.redBackgroundColor).toBe( lightTheme.emotion.colors.redBackgroundColor ) // Should derive for blue (main color valid) expect(theme.colors.blueBackgroundColor).toBe("rgba(0, 0, 255, 0.1)") }) // Text theme colors it.each([ ["#ffabab", "#ffabab"], // Using red40 from colors.ts ["ffabab", "#ffabab"], // Handles no leading # ["rgb(136, 51, 51)", "rgb(136, 51, 51)"], ["rgba(180, 69, 78, 0.1)", "rgba(180, 69, 78, 0.1)"], ["red", "red"], ])("uses configured text theme colors if set", (color, expectedColor) => { const themeInput: Partial<CustomThemeConfig> = { redTextColor: color, } const theme = createEmotionTheme(themeInput, lightTheme) expect(theme.colors.redTextColor).toBe(expectedColor) expect(theme.colors.orangeTextColor).toBe( lightTheme.emotion.colors.orangeTextColor ) expect(theme.colors.yellowTextColor).toBe( lightTheme.emotion.colors.yellowTextColor ) expect(theme.colors.blueTextColor).toBe( lightTheme.emotion.colors.blueTextColor ) expect(theme.colors.greenTextColor).toBe( lightTheme.emotion.colors.greenTextColor ) expect(theme.colors.violetTextColor).toBe( lightTheme.emotion.colors.violetTextColor ) expect(theme.colors.grayTextColor).toBe( lightTheme.emotion.colors.grayTextColor ) }) it.each([ // Test invalid text color values "invalid", "rgb(255, 0, 0", // Missing closing parenthesis "corgi", // Invalid color name "#G00000", // Invalid hex code ])( "logs a warning and falls back to default for invalid text theme colors '%s'", color => { const logWarningSpy = vi.spyOn(LOG, "warn") const themeInput: Partial<CustomThemeConfig> = { orangeTextColor: color, } const theme = createEmotionTheme(themeInput, lightTheme) expect(logWarningSpy).toHaveBeenCalledWith( `Invalid color passed for orangeTextColor in theme: "${color}"` ) // Falls back to default orange text expect(theme.colors.orangeTextColor).toBe( lightTheme.emotion.colors.orangeTextColor ) // All others use defaults expect(theme.colors.redTextColor).toBe( lightTheme.emotion.colors.redTextColor ) expect(theme.colors.yellowTextColor).toBe( lightTheme.emotion.colors.yellowTextColor ) expect(theme.colors.blueTextColor).toBe( lightTheme.emotion.colors.blueTextColor ) expect(theme.colors.greenTextColor).toBe( lightTheme.emotion.colors.greenTextColor ) expect(theme.colors.violetTextColor).toBe( lightTheme.emotion.colors.violetTextColor ) expect(theme.colors.grayTextColor).toBe( lightTheme.emotion.colors.grayTextColor ) } ) it("default text colors are set correctly for light theme", () => { const themeInput: Partial<CustomThemeConfig> = { base: CustomThemeConfig.BaseTheme.LIGHT, } const theme = createEmotionTheme(themeInput, lightTheme) expect(theme.colors.redTextColor).toBe( lightTheme.emotion.colors.redTextColor ) expect(theme.colors.orangeTextColor).toBe( lightTheme.emotion.colors.orangeTextColor ) expect(theme.colors.yellowTextColor).toBe( lightTheme.emotion.colors.yellowTextColor ) expect(theme.colors.blueTextColor).toBe( lightTheme.emotion.colors.blueTextColor ) expect(theme.colors.greenTextColor).toBe( lightTheme.emotion.colors.greenTextColor ) expect(theme.colors.violetTextColor).toBe( lightTheme.emotion.colors.violetTextColor ) expect(theme.colors.grayTextColor).toBe( lightTheme.emotion.colors.grayTextColor ) }) it("default text colors are set correctly for dark theme", () => { const themeInput: Partial<CustomThemeConfig> = { base: CustomThemeConfig.BaseTheme.DARK, } const theme = createEmotionTheme(themeInput, darkTheme) expect(theme.colors.redTextColor).toBe( darkTheme.emotion.colors.redTextColor ) expect(theme.colors.orangeTextColor).toBe( darkTheme.emotion.colors.orangeTextColor ) expect(theme.colors.yellowTextColor).toBe( darkTheme.emotion.colors.yellowTextColor ) expect(theme.colors.blueTextColor).toBe( darkTheme.emotion.colors.blueTextColor ) expect(theme.colors.greenTextColor).toBe( darkTheme.emotion.colors.greenTextColor ) expect(theme.colors.violetTextColor).toBe( darkTheme.emotion.colors.violetTextColor ) expect(theme.colors.grayTextColor).toBe( darkTheme.emotion.colors.grayTextColor ) }) it("derives text colors from main colors for light theme (darkens by 15%)", () => { const themeInput: Partial<CustomThemeConfig> = { base: CustomThemeConfig.BaseTheme.LIGHT, redColor: "#ff4b4b", // red70 blueColor: "#1c83e1", // blue70 // Don't set redTextColor or blueTextColor - should derive from main colors } const theme = createEmotionTheme(themeInput, lightTheme) // Should derive text colors from main colors (darken by 15%) expect(theme.colors.redTextColor).toBe(darken("#ff4b4b", 0.15)) expect(theme.colors.blueTextColor).toBe(darken("#1c83e1", 0.15)) // Should use defaults for colors without main colors configured expect(theme.colors.orangeTextColor).toBe( lightTheme.emotion.colors.orangeTextColor ) }) it("derives text colors from main colors for dark theme (lightens by 15%)", () => { const themeInput: Partial<CustomThemeConfig> = { base: CustomThemeConfig.BaseTheme.DARK, redColor: "#7d353b", // red100 blueColor: "#004280", // blue100 // Don't set redTextColor or blueTextColor - should derive from main colors } const theme = createEmotionTheme(themeInput, darkTheme) // Should derive text colors from main colors (lighten by 15%) expect(theme.colors.redTextColor).toBe(lighten("#7d353b", 0.15)) expect(theme.colors.blueTextColor).toBe(lighten("#004280", 0.15)) // Should use defaults for colors without main colors configured expect(theme.colors.orangeTextColor).toBe( darkTheme.emotion.colors.orangeTextColor ) }) it("prefers explicit text colors over derived from main colors", () => { const themeInput: Partial<CustomThemeConfig> = { base: CustomThemeConfig.BaseTheme.LIGHT, redColor: "#ff4b4b", // red70 from colors.ts - This would derive to darkened red redTextColor: "#ffabab", // red40 from colors.ts - This explicit value should take precedence } const theme = createEmotionTheme(themeInput, lightTheme) // Should use explicit text color, not derived from main color expect(theme.colors.redTextColor).toBe("#ffabab") expect(theme.colors.redTextColor).not.toBe(darken("#ff4b4b", 0.15)) }) it("handles mixed explicit and derived text colors correctly", () => { const themeInput: Partial<CustomThemeConfig> = { base: CustomThemeConfig.BaseTheme.LIGHT, // Explicit text colors redTextColor: "#ff9999", blueTextColor: "#9999ff", // Main colors that should derive text colors greenColor: "#00ff00", violetColor: "#8b00ff", // No yellow/orange/gray configs - should use defaults } const theme = createEmotionTheme(themeInput, lightTheme) // Should use explicit text colors expect(theme.colors.redTextColor).toBe("#ff9999") expect(theme.colors.blueTextColor).toBe("#9999ff") // Should derive from main colors (darken by 15% for light theme) expect(theme.colors.greenTextColor).toBe(darken("#00ff00", 0.15)) expect(theme.colors.violetTextColor).toBe(darken("#8b00ff", 0.15)) // Should use defaults expect(theme.colors.orangeTextColor).toBe( lightTheme.emotion.colors.orangeTextColor ) expect(theme.colors.yellowTextColor).toBe( lightTheme.emotion.colors.yellowTextColor ) expect(theme.colors.grayTextColor).toBe( lightTheme.emotion.colors.grayTextColor ) }) it("falls back to default when main color is invalid and no explicit text color provided", () => { const logWarningSpy = vi.spyOn(LOG, "warn") const themeInput: Partial<CustomThemeConfig> = { base: CustomThemeConfig.BaseTheme.LIGHT, redColor: "invalid-color", // Invalid main color blueColor: "#0000ff", // Valid main color } const theme = createEmotionTheme(themeInput, lightTheme) expect(logWarningSpy).toHaveBeenCalledWith( `Invalid color passed for redColor in theme: "invalid-color"` ) // Should use default for red (main color invalid) expect(theme.colors.redTextColor).toBe( lightTheme.emotion.colors.redTextColor ) // Should derive for blue (main color valid) expect(theme.colors.blueTextColor).toBe(darken("#0000ff", 0.15)) }) it("handles mixed explicit and derived text colors correctly for dark theme", () => { const themeInput: Partial<CustomThemeConfig> = { base: CustomThemeConfig.BaseTheme.DARK, // Explicit text colors redTextColor: "#ff9999", blueTextColor: "#9999ff", // Main colors that should derive text colors greenColor: "#008800", violetColor: "#6600bb", // No yellow/orange/gray configs - should use defaults } const theme = createEmotionTheme(themeInput, darkTheme) // Should use explicit text colors expect(theme.colors.redTextColor).toBe("#ff9999") expect(theme.colors.blueTextColor).toBe("#9999ff") // Should derive from main colors (lighten by 15% for dark theme) expect(theme.colors.greenTextColor).toBe(lighten("#008800", 0.15)) expect(theme.colors.violetTextColor).toBe(lighten("#6600bb", 0.15)) // Should use defaults expect(theme.colors.orangeTextColor).toBe( darkTheme.emotion.colors.orangeTextColor ) expect(theme.colors.yellowTextColor).toBe( darkTheme.emotion.colors.yellowTextColor ) expect(theme.colors.grayTextColor).toBe( darkTheme.emotion.colors.grayTextColor ) }) it("falls back to default when main color is invalid and no explicit text color provided for dark theme", () => { const logWarningSpy = vi.spyOn(LOG, "warn") const themeInput: Partial<CustomThemeConfig> = { base: CustomThemeConfig.BaseTheme.DARK, redColor: "invalid-color", // Invalid main color blueColor: "#000080", // Valid main color } const theme = createEmotionTheme(themeInput, darkTheme) expect(logWarningSpy).toHaveBeenCalledWith( `Invalid color passed for redColor in theme: "invalid-color"` ) // Should use default for red (main color invalid) expect(theme.colors.redTextColor).toBe( darkTheme.emotion.colors.redTextColor ) // Should derive for blue (main color valid, lighten by 15% for dark theme) expect(theme.colors.blueTextColor).toBe(lighten("#000080", 0.15)) }) it("handles mixed valid/invalid main colors for text color derivation", () => { const logWarningSpy = vi.spyOn(LOG, "warn") const themeInput: Partial<CustomThemeConfig> = { base: CustomThemeConfig.BaseTheme.LIGHT, redColor: "invalid", // Invalid main color blueColor: "#0000ff", // Valid main color } const theme = createEmotionTheme(themeInput, lightTheme) expect(logWarningSpy).toHaveBeenCalledWith( `Invalid color passed for redColor in theme: "invalid"` ) // Should use default for red (main color invalid) expect(theme.colors.redTextColor).toBe( lightTheme.emotion.colors.redTextColor ) // Should derive for blue (main color valid) expect(theme.colors.blueTextColor).toBe(darken("#0000ff", 0.15)) }) // Link color handling it("uses configured linkColor when provided", () => { const themeInput: Partial<CustomThemeConfig> = { linkColor: "#ff0000", blueTextColor: "#0000ff", // This should be ignored when linkColor is set } const theme = createEmotionTheme(themeInput, lightTheme) expect(theme.colors.link).toBe("#ff0000") }) it("falls back to configured blueTextColor when linkColor not provided", () => { const themeInput: Partial<CustomThemeConfig> = { blueTextColor: "#0000ff", // linkColor intentionally not set } const theme = createEmotionTheme(themeInput, lightTheme) expect(theme.colors.link).toBe("#0000ff") expect(theme.colors.blueTextColor).toBe("#0000ff") }) it("falls back to blueTextColor derived from blueColor when linkColor & blueTextColor not provided (light theme)", () => { const themeInput: Partial<CustomThemeConfig> = { base: CustomThemeConfig.BaseTheme.LIGHT, blueColor: "#1c83e1", // blue70 // linkColor and blueTextColor intentionally not set } const theme = createEmotionTheme(themeInput, lightTheme) const expectedDerivedBlueTextColor = darken("#1c83e1", 0.15) expect(theme.colors.blueTextColor).toBe(expectedDerivedBlueTextColor) expect(theme.colors.link).toBe(expectedDerivedBlueTextColor) }) it("falls back to blueTextColor derived from blueColor when linkColor & blueTextColor not provided (dark theme)", () => { const themeInput: Partial<CustomThemeConfig> = { base: CustomThemeConfig.BaseTheme.DARK, blueColor: "#1c83e1", // blue70 // linkColor and blueTextColor intentionally not set } const theme = createEmotionTheme(themeInput, darkTheme) const expectedDerivedBlueTextColor = lighten("#1c83e1", 0.15) expect(theme.colors.blueTextColor).toBe(expectedDerivedBlueTextColor) expect(theme.colors.link).toBe(expectedDerivedBlueTextColor) }) it("falls back to default blueTextColor when no link-related colors are configured (light theme)", () => { const themeInput: Partial<CustomThemeConfig> = { base: CustomThemeConfig.BaseTheme.LIGHT, // No linkColor, blueTextColor, or blueColor configured } const theme = createEmotionTheme(themeInput, lightTheme) expect(theme.colors.link).toBe(lightTheme.emotion.colors.blueTextColor) expect(theme.colors.blueTextColor).toBe( lightTheme.emotion.colors.blueTextColor ) }) it("falls back to default blueTextColor when no link-related colors are configured (dark theme)", () => { const themeInput: Partial<CustomThemeConfig> = { base: CustomThemeConfig.BaseTheme.DARK, // No linkColor, blueTextColor, or blueColor configured } const theme = createEmotionTheme(themeInput, darkTheme) expect(theme.colors.link).toBe(darkTheme.emotion.colors.blueTextColor) expect(theme.colors.blueTextColor).toBe( darkTheme.emotion.colors.blueTextColor ) }) it("prioritizes linkColor over blueTextColor when both are configured", () => { const themeInput: Partial<CustomThemeConfig> = { linkColor: "#ff0000", blueTextColor: "#0000ff", } const theme = createEmotionTheme(themeInput, lightTheme) expect(theme.colors.link).toBe("#ff0000") expect(theme.colors.blueTextColor).toBe("#0000ff") }) it("handles invalid linkColor by logging warning and falling back to default blueTextColor", () => { const logWarningSpy = vi.spyOn(LOG, "warn") const themeInput: Partial<CustomThemeConfig> = { linkColor: "invalid-color", blueTextColor: "#0000ff", } const theme = createEmotionTheme(themeInput, lightTheme) expect(logWarningSpy).toHaveBeenCalledWith( `Invalid color passed for linkColor in theme: "invalid-color"` ) // Should fall back to blueTextColor since linkColor is invalid expect(theme.colors.link).toBe("#0000ff") expect(theme.colors.blueTextColor).toBe("#0000ff") }) it("falls back to default when both linkColor and blueTextColor are invalid", () => { const logWarningSpy = vi.spyOn(LOG, "warn") const themeInput: Partial<CustomThemeConfig> = { linkColor: "invalid-color", blueTextColor: "invalid-blue-color", } const theme = createEmotionTheme(themeInput, lightTheme) expect(logWarningSpy).toHaveBeenCalledWith( `Invalid color passed for linkColor in theme: "invalid-color"` ) expect(logWarningSpy).toHaveBeenCalledWith( `Invalid color passed for blueTextColor in theme: "invalid-blue-color"` ) // Should use default blueTextColor since both colors are invalid expect(theme.colors.link).toBe(lightTheme.emotion.colors.blueTextColor) expect(theme.colors.blueTextColor).toBe( lightTheme.emotion.colors.blueTextColor ) }) it("handles linkColor without # prefix correctly", () => { const themeInput: Partial<CustomThemeConfig> = { linkColor: "0000ff", // Blue without # prefix } const theme = createEmotionTheme(themeInput, lightTheme) expect(theme.colors.link).toBe("#0000ff") }) // Code text color handling it("uses configured codeTextColor when provided", () => { const themeInput: Partial<ICustomThemeConfig> = { codeTextColor: "#ff0000", greenTextColor: "#00aa00", // This should be ignored when codeTextColor is set } const theme = createEmotionTheme(themeInput, lightTheme) expect(theme.colors.codeTextColor).toBe("#ff0000") expect(theme.colors.greenTextColor).toBe("#00aa00") }) it("falls back to configured greenTextColor when codeTextColor not provided", () => { const themeInput: Partial<CustomThemeConfig> = { greenTextColor: "#00aa00", // codeTextColor intentionally not set } const theme = createEmotionTheme(themeInput, lightTheme) expect(theme.colors.codeTextColor).toBe("#00aa00") expect(theme.colors.greenTextColor).toBe("#00aa00") }) it("falls back to greenTextColor derived from greenColor when codeTextColor & greenTextColor not provided (light theme)", () => { const themeInput: Partial<CustomThemeConfig> = { base: CustomThemeConfig.BaseTheme.LIGHT, greenColor: "#29b09d", // green70 // codeTextColor and greenTextColor intentionally not set } const theme = createEmotionTheme(themeInput, lightTheme) const expectedDerivedGreenTextColor = darken("#29b09d", 0.15) expect(theme.colors.greenTextColor).toBe(expectedDerivedGreenTextColor) expect(theme.colors.codeTextColor).toBe(expectedDerivedGreenTextColor) }) it("falls back to greenTextColor derived from greenColor when codeTextColor & greenTextColor not provided (dark theme)", () => { const themeInput: Partial<CustomThemeConfig> = { base: CustomThemeConfig.BaseTheme.DARK, greenColor: "#177233", // green100 // codeTextColor and greenTextColor intentionally not set } const theme = createEmotionTheme(themeInput, darkTheme) const expectedDerivedGreenTextColor = lighten("#177233", 0.15) expect(theme.colors.greenTextColor).toBe(expectedDerivedGreenTextColor) expect(theme.colors.codeTextColor).toBe(expectedDerivedGreenTextColor) }) it("falls back to default greenTextColor when no code or green colors configured (light theme)", () => { const themeInput: Partial<CustomThemeConfig> = { base: CustomThemeConfig.BaseTheme.LIGHT, // No codeTextColor, greenColor, or greenTextColor configured } const theme = createEmotionTheme(themeInput, lightTheme) expect(theme.colors.codeTextColor).toBe( lightTheme.emotion.colors.greenTextColor ) expect(theme.colors.greenTextColor).toBe( lightTheme.emotion.colors.greenTextColor ) }) it("falls back to default greenTextColor when no code or green colors configured (dark theme)", () => { const themeInput: Partial<CustomThemeConfig> = { base: CustomThemeConfig.BaseTheme.DARK, // No codeTextColor, greenColor, or greenTextColor configured } const theme = createEmotionTheme(themeInput, darkTheme) expect(theme.colors.codeTextColor).toBe( darkTheme.emotion.colors.greenTextColor ) expect(theme.colors.greenTextColor).toBe( darkTheme.emotion.colors.greenTextColor ) }) it("prioritizes codeTextColor over greenTextColor when both are configured", () => { const themeInput: Partial<ICustomThemeConfig> = { codeTextColor: "#ff0000", // Explicit code text color greenTextColor: "#00ff00", // This should be ignored for codeTextColor } const theme = createEmotionTheme(themeInput, lightTheme) expect(theme.colors.codeTextColor).toBe("#ff0000") expect(theme.colors.greenTextColor).toBe("#00ff00") }) it("prioritizes codeTextColor over derived greenTextColor from greenColor", () => { const themeInput: Partial<ICustomThemeConfig> = { base: CustomThemeConfig.BaseTheme.LIGHT, codeTextColor: "#ff0000", // Explicit code text color greenColor: "#29b09d", // This would derive a different greenTextColor } const theme = createEmotionTheme(themeInput, lightTheme) const expectedDerivedGreenTextColor = darken("#29b09d", 0.15) expect(theme.colors.greenTextColor).toBe(expectedDerivedGreenTextColor) expect(theme.colors.codeTextColor).toBe("#ff0000") // Should use explicit codeTextColor }) it("handles invalid codeTextColor by logging warning and falling back to default greenTextColor", () => { const logWarningSpy = vi.spyOn(LOG, "warn") const themeInput: Partial<ICustomThemeConfig> = { codeTextColor: "invalid-color", greenTextColor: "#00aa00", } const theme = createEmotionTheme(themeInput, lightTheme) expect(logWarningSpy).toHaveBeenCalledWith( `Invalid color passed for codeTextColor in theme: "invalid-color"` ) // Should fall back to greenTextColor since codeTextColor is invalid expect(theme.colors.codeTextColor).toBe("#00aa00") expect(theme.colors.greenTextColor).toBe("#00aa00") }) it("falls back to default when both codeTextColor and greenTextColor are invalid", () => { const logWarningSpy = vi.spyOn(LOG, "warn") const themeInput: Partial<ICustomThemeConfig> = { base: CustomThemeConfig.BaseTheme.LIGHT, codeTextColor: "invalid-code-color", greenTextColor: "invalid-green-color", } const theme = createEmotionTheme(themeInput, lightTheme) expect(logWarningSpy).toHaveBeenCalledWith( `Invalid color passed for codeTextColor in theme: "invalid-code-color"` ) expect(logWarningSpy).toHaveBeenCalledWith( `Invalid color passed for greenTextColor in theme: "invalid-green-color"` ) // Should use default greenTextColor since both colors are invalid expect(theme.colors.greenTextColor).toBe( lightTheme.emotion.colors.greenTextColor ) expect(theme.colors.codeTextColor).toBe( lightTheme.emotion.colors.greenTextColor ) }) it("handles codeTextColor without # prefix correctly", () => { const themeInput: Partial<ICustomThemeConfig> = { codeTextColor: "00ff00", // Green without # prefix } const theme = createEmotionTheme(themeInput, lightTheme) expect(theme.colors.codeTextColor).toBe("#00ff00") }) // Categorical chart colors it.each([ // Test valid color values [ ["red", "orange", "blue", "pink", "purple"], ["red", "orange", "blue", "pink", "purple"], ], // Valid hex codes passed without leading # [ [ "7fc97f", "beaed4", "fdc086", "ffff99", "386cb0", "f0027f", "bf5b17", "666666", ], [ "#7fc97f", "#beaed4", "#fdc086", "#ffff99", "#386cb0", "#f0027f", "#bf5b17", "#666666", ], ], [ [ "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf", ], [ "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf", ], ], [ [ "rgb(255, 0, 0)", "rgb(255, 165, 0)", "rgb(0, 0, 255)", "rgb(255, 192, 192)", "rgb(128, 0, 128)", ], [ "rgb(255, 0, 0)", "rgb(255, 165, 0)", "rgb(0, 0, 255)", "rgb(255, 192, 192)", "rgb(128, 0, 128)", ], ], ])( "correctly handles setting of categorical color config '%s'", (chartCategoricalColors, expectedCategoricalColors) => { const themeInput: Partial<CustomThemeConfig> = { chartCategoricalColors, } const theme = createEmotionTheme(themeInput) expect(theme.colors.chartCategoricalColors).toEqual( expectedCategoricalColors ) } ) it.each([ // Test invalid color values [ ["red", "orange", "blue", "pink", "purple", "invalid"], ["red", "orange", "blue", "pink", "purple"], ], [ [ "7fc97f", "beaed4", "fdc086", "ffff99", "386cb0", "f0027f", "bf5b17", "666666", "invalid", ], [ "#7fc97f", "#beaed4", "#fdc086", "#ffff99", "#386cb0", "#f0027f", "#bf5b17", "#666666", ], ], [ [ "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf", "invalid", ], [ "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf", ], ], [ [ "rgb(255, 0, 0)", "rgb(255, 165, 0)", "rgb(0, 0, 255)", "rgb(255, 192, 192)", "rgb(128, 0, 128)", "invalid", ], [ "rgb(255, 0, 0)", "rgb(255, 165, 0)", "rgb(0, 0, 255)", "rgb(255, 192, 192)", "rgb(128, 0, 128)", ], ], [ // When no valid colors are passed, returns default colors ["invalid"], [ "#0068c9", "#83c9ff", "#ff2b2b", "#ffabab", "#29b09d", "#7defa1", "#ff8700", "#ffd16a", "#6d3fc0", "#d5dae5", ], ], ])( "logs a warning and removes any invalid categorical color configs '%s'", (chartCategoricalColors, expectedCategoricalColors) => { const logWarningSpy = vi.spyOn(LOG, "warn") const themeInput: Partial<CustomThemeConfig> = { chartCategoricalColors, } const theme = createEmotionTheme(themeInput) expect(logWarningSpy).toHaveBeenCalledWith( `Invalid color passed for chartCategoricalColors in theme: "invalid"` ) expect(theme.colors.chartCategoricalColors).toEqual( expectedCategoricalColors ) } ) // Sequential chart colors it.each([ // Test valid color values [ [ "red", "orange", "yellow", "green", "blue", "purple", "pink", "gray", "black", "white", ], [ "red", "orange", "yellow", "green", "blue", "purple", "pink", "gray", "black", "white", ], ], // Valid hex codes [ [ "#dffde9", "#c0fcd3", "#9ef6bb", "#7defa1", "#5ce488", "#3dd56d", "#21c354", "#09ab3b", "#158237", "#177233", ], [ "#dffde9", "#c0fcd3", "#9ef6bb", "#7defa1", "#5ce488", "#3dd56d", "#21c354", "#09ab3b", "#158237", "#177233", ], ], // Valid hex codes passed without leading # [ [ "dffde9", "c0fcd3", "9ef6bb", "7defa1", "5ce488", "3dd56d", "21c354", "09ab3b", "158237", "177233", ], [ "#dffde9", "#c0fcd3", "#9ef6bb", "#7defa1", "#5ce488", "#3dd56d", "#21c354", "#09ab3b", "#158237", "#177233", ], ], // Valid rgb values [ [ "rgb(255, 0, 0)", "rgb(255, 165, 0)", "rgb(255, 255, 0)", "rgb(0, 255, 0)", "rgb(0, 0, 255)", "rgb(128, 0, 128)", "rgb(255, 192, 192)", "rgb(128, 128, 128)", "rgb(0, 0, 0)", "rgb(255, 255, 255)", ], [ "rgb(255, 0, 0)", "rgb(255, 165, 0)", "rgb(255, 255, 0)", "rgb(0, 255, 0)", "rgb(0, 0, 255)", "rgb(128, 0, 128)", "rgb(255, 192, 192)", "rgb(128, 128, 128)", "rgb(0, 0, 0)", "rgb(255, 255, 255)", ], ], ])( "correctly handles setting of sequential color config '%s'", (chartSequentialColors, expectedSequentialColors) => { const themeInput: Partial<CustomThemeConfig> = { chartSequentialColors, } const theme = createEmotionTheme(themeInput) expect(theme.colors.chartSequentialColors).toEqual( expectedSequentialColors ) } ) it.each([ // Test invalid color values [ [ "red", "orange", "yellow", "green", "blue", "purple", "pink", "gray", "black", "invalid", ], [ "#e4f5ff", "#c7ebff", "#a6dcff", "#83c9ff", "#60b4ff", "#3d9df3", "#1c83e1", "#0068c9", "#0054a3", "#004280", ], ], [ // When the array doesn't contain 10 colors, returns default colors ["invalid"], [ "#e4f5ff", "#c7ebff", "#a6dcff", "#83c9ff", "#60b4ff", "#3d9df3", "#1c83e1", "#0068c9", "#0054a3", "#004280", ], ], ])( "logs a warning and removes any invalid sequential color configs '%s'", (chartSequentialColors, expectedSequentialColors) => { const logWarningSpy = vi.spyOn(LOG, "warn") const themeInput: Partial<CustomThemeConfig> = { chartSequentialColors, } const theme = createEmotionTheme(themeInput) // Error log from parseColor (invalid color) expect(logWarningSpy).toHaveBeenCalledWith( `Invalid color passed for chartSequentialColors in theme: "invalid"` ) // Error log from validateChartColors (<10 colors) expect(logWarningSpy).toHaveBeenCalledWith( `Invalid chartSequentialColors: ${chartSequentialColors.toString()}. Falling back to default chartSequentialColors.` ) expect(theme.colors.chartSequentialColors).toEqual( expectedSequentialColors ) } ) // Diverging chart colors it.each([ // Test hex colors [ [ "#ff0000", "#ff3300", "#ff6600", "#ff9900", "#ffcc00", "#00ccff", "#0099ff", "#0066ff", "#0033ff", "#0000ff", ], [ "#ff0000", "#ff3300", "#ff6600", "#ff9900", "#ffcc00", "#00ccff", "#0099ff", "#0066ff", "#0033ff", "#0000ff", ], ], // Test rgb colors [ [ "rgb(255, 0, 0)", "rgb(255, 51, 0)", "rgb(255, 102, 0)", "rgb(255, 153, 0)", "rgb(255, 204, 0)", "rgb(0, 204, 255)", "rgb(0, 153, 255)", "rgb(0, 102, 255)", "rgb(0, 51, 255)", "rgb(0, 0, 255)", ], [ "rgb(255, 0, 0)", "rgb(255, 51, 0)", "rgb(255, 102, 0)", "rgb(255, 153, 0)", "rgb(255, 204, 0)", "rgb(0, 204, 255)", "rgb(0, 153, 255)", "rgb(0, 102, 255)", "rgb(0, 51, 255)", "rgb(0, 0, 255)", ], ], ])( "correctly handles setting of diverging color config '%s'", (chartDivergingColors, expectedDivergingColors) => { const themeInput: Partial<CustomThemeConfig> = { chartDivergingColors, } const theme = createEmotionTheme(themeInput) expect(theme.colors.chartDivergingColors).toEqual( expectedDivergingColors ) } ) it.each([ // Test invalid color values [ [ "red", "orange", "yellow", "green", "blue", "purple", "pink", "gray", "black", "invalid", ], [ "#7d353b", "#bd4043", "#ff4b4b", "#ff8c8c", "#ffc7c7", "#a6dcff", "#60b4ff", "#1c83e1", "#0054a3", "#004280", ], ], [ // When the array doesn't contain 10 colors, returns default colors ["invalid"], [ "#7d353b", "#bd4043", "#ff4b4b", "#ff8c8c", "#ffc7c7", "#a6dcff", "#60b4ff", "#1c83e1", "#0054a3", "#004280", ], ], ])( "logs a warning and removes any invalid diverging color configs '%s'", (chartDivergingColors, expectedDivergingColors) => { const logWarningSpy = vi.spyOn(LOG, "warn") const themeInput: Partial<CustomThemeConfig> = { chartDivergingColors, } const theme = createEmotionTheme(themeInput) // Error log from parseColor (invalid color) expect(logWarningSpy).toHaveBeenCalledWith( `Invalid color passed for chartDivergingColors in theme: "invalid"` ) // Error log from validateChartColors (<10 colors) expect(logWarningSpy).toHaveBeenCalledWith( `Invalid chartDivergingColors: ${chartDivergingColors.toString()}. Falling back to default chartDivergingColors.` ) expect(theme.colors.chartDivergingColors).toEqual( expectedDivergingColors ) } ) // == Theme radii properties == it("adapts the radii theme props if baseRadius is provided", () => { const themeInput: Partial<CustomThemeConfig> = { baseRadius: "1.2rem", } const theme = createEmotionTheme(themeInput) expect(theme.radii.default).toBe("1.2rem") expect(theme.radii.md).toBe("0.6rem") expect(theme.radii.xl).toBe("1.8rem") expect(theme.radii.xxl).toBe("2.4rem") }) it.each([ // Test keyword values ["full", "1.4rem", "0.7rem", "2.1rem", "2.8rem"], ["none", "0rem", "0rem", "0rem", "0rem"], ["small", "0.35rem", "0.17rem", "0.52rem", "0.7rem"], ["medium", "0.5rem", "0.25rem", "0.75rem", "1rem"], ["large", "1rem", "0.5rem", "1.5rem", "2rem"], // Test rem values ["0.8rem", "0.8rem", "0.4rem", "1.2rem", "1.6rem"], ["2rem", "2rem", "1rem", "3rem", "4rem"], // Test px values ["10px", "10px", "5px", "15px", "20px"], ["24px", "24px", "12px", "36px", "48px"], // Test with whitespace and uppercase [" FULL ", "1.4rem", "0.7rem", "2.1rem", "2.8rem"], [" medium ", "0.5rem", "0.25rem", "0.75rem", "1rem"], ["2 rem ", "2rem", "1rem", "3rem", "4rem"], // Test only numbers: ["10", "10px", "5px", "15px", "20px"], ["24foo", "24px", "12px", "36px", "48px"], ])( "correctly applies baseRadius '%s'", (baseRadius, expectedDefault, expectedMd, expectedXl, expectedXxl) => { const themeInput: Partial<CustomThemeConfig> = { baseRadius, } const theme = createEmotionTheme(themeInput) expect(theme.radii.default).toBe(expectedDefault) expect(theme.radii.md).toBe(expectedMd) expect(theme.radii.xl).toBe(expectedXl) expect(theme.radii.xxl).toBe(expectedXxl) } ) it.each([ "invalid", "rem", // Missing number "px", // Missing number ])( "logs a warning and falls back to default for invalid baseRadius '%s'", invalidBaseRadius => { const logWarningSpy = vi.spyOn(LOG, "warn") const themeInput: Partial<CustomThemeConfig> = { baseRadius: invalidBaseRadius, } const theme = createEmotionTheme(themeInput) // Should log an error expect(logWarningSpy).toHaveBeenCalledWith( `Invalid base radius: ${invalidBaseRadius}. Falling back to default base radius.` ) // Should fall back to default values expect(theme.radii.default).toBe(baseTheme.emotion.radii.default) expect(theme.radii.md).toBe(baseTheme.emotion.radii.md) expect(theme.radii.xl).toBe(baseTheme.emotion.radii.xl) expect(theme.radii.xxl).toBe(baseTheme.emotion.radii.xxl) } ) it.each([ // Test keyword values ["full", "1.4rem", "0.5rem", "0.25rem", "0.75rem", "1rem"], ["none", "0rem", "0.5rem", "0.25rem", "0.75rem", "1rem"], ["small", "0.35rem", "0.5rem", "0.25rem", "0.75rem", "1rem"], ["medium", "0.5rem", "0.5rem", "0.25rem", "0.75rem", "1rem"], ["large", "1rem", "0.5rem", "0.25rem", "0.75rem", "1rem"], // Test rem values ["0.8rem", "0.8rem", "0.5rem", "0.25rem", "0.75rem", "1rem"], ["2rem", "2rem", "0.5rem", "0.25rem", "0.75rem", "1rem"], // Test px values ["10px", "10px", "0.5rem", "0.25rem", "0.75rem", "1rem"], ["24px", "24px", "0.5rem", "0.25rem", "0.75rem", "1rem"], // Test with whitespace and uppercase [" FULL ", "1.4rem", "0.5rem", "0.25rem", "0.75rem", "1rem"], [" medium ", "0.5rem", "0.5rem", "0.25rem", "0.75rem", "1rem"], ["2 rem ", "2rem", "0.5rem", "0.25rem", "0.75rem", "1rem"], // Test only numbers: ["10", "10px", "0.5rem", "0.25rem", "0.75rem", "1rem"], ["24foo", "24px", "0.5rem", "0.25rem", "0.75rem", "1rem"], ])( "correctly handles buttonRadius config '%s' (does not impact other radii values)", ( buttonRadius, expectedButtonRadius, expectedDefault, expectedMd, expectedXl, expectedXxl ) => { const themeInput: Partial<CustomThemeConfig> = { buttonRadius, } const theme = createEmotionTheme(themeInput) expect(theme.radii.button).toBe(expectedButtonRadius) expect(theme.radii.default).toBe(expectedDefault) expect(theme.radii.md).toBe(expectedMd) expect(theme.radii.xl).toBe(expectedXl) expect(theme.radii.xxl).toBe(expectedXxl) } ) it.each([ "invalid", "rem", // Missing number "px", // Missing number "", // Empty string ])( "logs a warning and falls back to default for invalid buttonRadius '%s'", invalidButtonRadius => { const logWarningSpy = vi.spyOn(LOG, "warn") const themeInput: Partial<CustomThemeConfig> = { buttonRadius: invalidButtonRadius, } const theme = createEmotionTheme(themeInput) // Should log an error expect(logWarningSpy).toHaveBeenCalledWith( `Invalid button radius: ${invalidButtonRadius}. Falling back to default button radius.` ) // Should fall back to default values expect(theme.radii.button).toBe(baseTheme.emotion.radii.button) expect(theme.radii.default).toBe(baseTheme.emotion.radii.default) expect(theme.radii.md).toBe(baseTheme.emotion.radii.md) expect(theme.radii.xl).toBe(baseTheme.emotion.radii.xl) expect(theme.radii.xxl).toBe(baseTheme.emotion.radii.xxl) } ) it("sets buttonRadius based on baseRadius if buttonRadius not configured", () => { const themeInput: Partial<CustomThemeConfig> = { baseRadius: "0.77rem", } const theme = createEmotionTheme(themeInput) expect(theme.radii.button).toBe("0.77rem") expect(theme.radii.default).toBe("0.77rem") expect(theme.radii.md).toBe("0.39rem") expect(theme.radii.xl).toBe("1.16rem") expect(theme.radii.xxl).toBe("1.54rem") }) // == Theme font size properties == it("uses baseFontSize when configured", () => { const themeInput: Partial<CustomThemeConfig> = { baseFontSize: 18, } const theme = createEmotionTheme(themeInput) expect(theme.fontSizes.baseFontSize).toBe(18) }) it("maintains default if configured baseFontSize is not > 0", () => { const themeInput: Partial<CustomThemeConfig> = { baseFontSize: 0, } const theme = createEmotionTheme(themeInput) expect(theme.fontSizes.baseFontSize).toBe(16) }) it.each([ // Test valid codeFontSize values // Inline code font size unaffected, set to 0.75em ["0.875rem", "0.875rem", "0.75em"], ["0.875REM", "0.875rem", "0.75em"], ["14px", "14px", "0.75em"], ["14PX", "14px", "0.75em"], ["15", "15px", "0.75em"], ])( "correctly applies codeFontSize and inlineCodeFontSize '%s'", (codeFontSize, expectedCodeFontSize, expectedInlineCodeFontSize) => { const themeInput: Partial<CustomThemeConfig> = { codeFontSize, } const theme = createEmotionTheme(themeInput) expect(theme.fontSizes.codeFontSize).toBe(expectedCodeFontSize) expect(theme.fontSizes.inlineCodeFontSize).toBe( expectedInlineCodeFontSize ) } ) it.each([ // Test invalid codeFontSize values ["invalid", "0.875rem", "0.75em"], ["rem", "0.875rem", "0.75em"], ["px", "0.875rem", "0.75em"], [" ", "0.875rem", "0.75em"], ])( "logs a warning and falls back to default for any invalid codeFontSize '%s'", (codeFontSize, expectedCodeFontSize, expectedInlineCodeFontSize) => { const logWarningSpy = vi.spyOn(LOG, "warn") const themeInput: Partial<CustomThemeConfig> = { codeFontSize, } const theme = createEmotionTheme(themeInput) // Should log an error with the actual codeFontSize value expect(logWarningSpy).toHaveBeenCalledWith( `Invalid size passed for codeFontSize in theme: ${codeFontSize}. Falling back to default codeFontSize.` ) expect(theme.fontSizes.codeFontSize).toBe(expectedCodeFontSize) expect(theme.fontSizes.inlineCodeFontSize).toBe( expectedInlineCodeFontSize ) } ) it.each([ // Test valid headingFontSize values [ ["3rem", "2.875rem"], ["3rem", "2.875rem", "1.75rem", "1.5rem", "1.25rem", "1rem"], ], [ ["3REM", "2.875REM"], ["3rem", "2.875rem", "1.75rem", "1.5rem", "1.25rem", "1rem"], ], [ ["50px", "45px"], ["3.125rem", "2.8125rem", "1.75rem", "1.5rem", "1.25rem", "1rem"], ], [ ["50PX", "45PX"], ["3.125rem", "2.8125rem", "1.75rem", "1.5rem", "1.25rem", "1rem"], ], [ ["50", "45"], ["3.125rem", "2.8125rem", "1.75rem", "1.5rem", "1.25rem", "1rem"], ], [ ["39.5px", "32.8px", "24.2px"], ["2.46875rem", "2.05rem", "1.5125rem", "1.5rem", "1.25rem", "1rem"], ], ])( "correctly applies headingFontSizes '%s'", (headingFontSizes, expectedHeadingFontSizes) => { const themeInput: Partial<CustomThemeConfig> = { headingFontSizes, } const theme = createEmotionTheme(themeInput) expect(theme.fontSizes.h1FontSize).toBe(expectedHeadingFontSizes[0]) expect(theme.fontSizes.h2FontSize).toBe(expectedHeadingFontSizes[1]) expect(theme.fontSizes.h3FontSize).toBe(expectedHeadingFontSizes[2]) expect(theme.fontSizes.h4FontSize).toBe(expectedHeadingFontSizes[3]) expect(theme.fontSizes.h5FontSize).toBe(expectedHeadingFontSizes[4]) expect(theme.fontSizes.h6FontSize).toBe(expectedHeadingFontSizes[5]) } ) it.each([ // Test invalid headingFontSize values [ "h1FontSize", ["Invalid"], ["2.75rem", "2.25rem", "1.75rem", "1.5rem", "1.25rem", "1rem"], ], [ "h2FontSize", ["3REM", "Invalid"], ["3rem", "2.25rem", "1.75rem", "1.5rem", "1.25rem", "1rem"], ], [ "h3FontSize", ["3rem", "2.875rem", "Invalid"], ["3rem", "2.875rem", "1.75rem", "1.5rem", "1.25rem", "1rem"], ], [ "h4FontSize", ["3rem", "2.875rem", "2.75rem", "Invalid"], ["3rem", "2.875rem", "2.75rem", "1.5rem", "1.25rem", "1rem"], ], [ "h5FontSize", ["3rem", "2.875rem", "2.75rem", "2.25rem", "Invalid"], ["3rem", "2.875rem", "2.75rem", "2.25rem", "1.25rem", "1rem"], ], [ "h6FontSize", ["3rem", "2.875rem", "2.75rem", "2.25rem", "2rem", "Invalid"], ["3rem", "2.875rem", "2.75rem", "2.25rem", "2rem", "1rem"], ], ])( "logs a warning and falls back to default for any invalid headingFontSizes '%s'", (invalidHeadingConfig, headingFontSizes, expectedHeadingFontSizes) => { const logWarningSpy = vi.spyOn(LOG, "warn") const themeInput: Partial<CustomThemeConfig> = { headingFontSizes, } const theme = createEmotionTheme(themeInput) // Should log an error with the actual codeFontSize value expect(logWarningSpy).toHaveBeenCalledWith( `Invalid size passed for ${invalidHeadingConfig} in headingFontSizes in theme: Invalid. Falling back to default ${invalidHeadingConfig} in headingFontSizes.` ) expect(theme.fontSizes.h1FontSize).toBe(expectedHeadingFontSizes[0]) expect(theme.fontSizes.h2FontSize).toBe(expectedHeadingFontSizes[1]) expect(theme.fontSizes.h3FontSize).toBe(expectedHeadingFontSizes[2]) expect(theme.fontSizes.h4FontSize).toBe(expectedHeadingFontSizes[3]) expect(theme.fontSizes.h5FontSize).toBe(expectedHeadingFontSizes[4]) expect(theme.fontSizes.h6FontSize).toBe(expectedHeadingFontSizes[5]) } ) // == Theme font weight properties == it.each([ // Test valid font weights [100, 100, 300, 400], [200, 200, 400, 500], [300, 300, 500, 600], [400, 400, 600, 700], [500, 500, 700, 800], [600, 600, 800, 900], ])( "sets the font weights based on the baseFontWeight config '%s'", (baseFontWeight, expectedNormal, expectedBold, expectedExtrabold) => { const logWarningSpy = vi.spyOn(LOG, "warn") const themeInput: Partial<CustomThemeConfig> = { baseFontWeight, } const theme = createEmotionTheme(themeInput) expect(logWarningSpy).not.toHaveBeenCalled() expect(theme.fontWeights.normal).toBe(expectedNormal) expect(theme.fontWeights.bold).toBe(expectedBold) expect(theme.fontWeights.extrabold).toBe(expectedExtrabold) } ) it.each([ // Test invalid font weights [150, 400, 600, 700], // Not an increment of 100 [700, 400, 600, 700], // Not between 100 and 600 [400.5, 400, 600, 700], // Not an integer ])( "logs a warning and falls back to default font weights if baseFontWeight is invalid '%s'", (baseFontWeight, expectedNormal, expectedBold, expectedExtrabold) => { const logWarningSpy = vi.spyOn(LOG, "warn") const themeInput: Partial<CustomThemeConfig> = { baseFontWeight, } const theme = createEmotionTheme(themeInput) expect(logWarningSpy).toHaveBeenCalledWith( `Invalid baseFontWeight: ${baseFontWeight} in theme. The baseFontWeight must be an integer 100-600, and an increment of 100. Falling back to default font weight.` ) expect(theme.fontWeights.normal).toBe(expectedNormal) expect(theme.fontWeights.bold).toBe(expectedBold) expect(theme.fontWeights.extrabold).toBe(expectedExtrabold) } ) it.each([ // Test valid font weights [100, 400, 600, 700, 100, 300, 400], [200, 400, 600, 700, 200, 400, 500], [300, 400, 600, 700, 300, 500, 600], [400, 400, 600, 700, 400, 600, 700], [500, 400, 600, 700, 500, 700, 800], [600, 400, 600, 700, 600, 800, 900], ])( "sets the font weights based on the codeFontWeight config '%s'", ( codeFontWeight, expectedNormal, expectedBold, expectedExtrabold, expectedCode, expectedCodeBold, expectedCodeExtraBold ) => { const logWarningSpy = vi.spyOn(LOG, "warn") const themeInput: Partial<CustomThemeConfig> = { codeFontWeight, } const theme = createEmotionTheme(themeInput) expect(logWarningSpy).not.toHaveBeenCalled() // baseFontWeight is not set, so the default font weights are used expect(theme.fontWeights.normal).toBe(expectedNormal) expect(theme.fontWeights.bold).toBe(expectedBold) expect(theme.fontWeights.extrabold).toBe(expectedExtrabold) // codeFontWeight is set, so it overrides the default code font weight expect(theme.fontWeights.code).toBe(expectedCode) expect(theme.fontWeights.codeBold).toBe(expectedCodeBold) expect(theme.fontWeights.codeExtraBold).toBe(expectedCodeExtraBold) } ) it.each([ // Test invalid font weights [700, 400, 600, 700, 400], // Not between 100 and 600 [800, 400, 600, 700, 400], // Not between 100 and 600 [900, 400, 600, 700, 400], // Not between 100 and 600 [150, 400, 600, 700, 400], // Not an increment of 100 [1000, 400, 600, 700, 400], // Not between 100 and 900 [400.5, 400, 600, 700, 400], // Not an integer ])( "logs a warning and falls back to default font weights if codeFontWeight is invalid '%s'", ( codeFontWeight, expectedNormal, expectedBold, expectedExtrabold, expectedCode ) => { const logWarningSpy = vi.spyOn(LOG, "warn") const themeInput: Partial<CustomThemeConfig> = { codeFontWeight, } const theme = createEmotionTheme(themeInput) expect(logWarningSpy).toHaveBeenCalledWith( `Invalid codeFontWeight: ${codeFontWeight} in theme. The codeFontWeight must be an integer 100-600, and an increment of 100. Falling back to default font weight.` ) expect(theme.fontWeights.normal).toBe(expectedNormal) expect(theme.fontWeights.bold).toBe(expectedBold) expect(theme.fontWeights.extrabold).toBe(expectedExtrabold) expect(theme.fontWeights.code).toBe(expectedCode) expect(theme.fontWeights.codeBold).toBe(expectedBold) expect(theme.fontWeights.codeExtraBold).toBe(expectedExtrabold) } ) it.each([ // Test valid headingFontWeights for h1-h6 [[100, 100, 100, 100, 100, 100]], [[200, 200, 200, 200, 200, 200]], [[300, 300, 300, 300, 300, 300]], [[400, 400, 400, 400, 400, 400]], [[500, 500, 500, 500, 500, 500]], [[600, 600, 600, 600, 600, 600]], [[700, 700, 700, 700, 700, 700]], [[800, 800, 800, 800, 800, 800]], [[900, 900, 900, 900, 900, 900]], ])( "sets the font weights based on the headingFontWeights configs '%s'", headingFontWeights => { const logWarningSpy = vi.spyOn(LOG, "warn") const themeInput: Partial<CustomThemeConfig> = { headingFontWeights, } const theme = createEmotionTheme(themeInput) expect(logWarningSpy).not.toHaveBeenCalled() expect(theme.fontWeights.h1FontWeight).toBe(headingFontWeights[0]) expect(theme.fontWeights.h2FontWeight).toBe(headingFontWeights[1]) expect(theme.fontWeights.h3FontWeight).toBe(headingFontWeights[2]) expect(theme.fontWeights.h4FontWeight).toBe(headingFontWeights[3]) expect(theme.fontWeights.h5FontWeight).toBe(headingFontWeights[4]) expect(theme.fontWeights.h6FontWeight).toBe(headingFontWeights[5]) } ) it.each([ // Test invalid font weights for h1-h6 { headingFontWeights: [150, 200, 300, 400, 500, 600], invalidFontWeight: 150, invalidFontWeightConfig: "h1FontWeight", expectedWeights: [ baseTheme.emotion.fontWeights.h1FontWeight, 200, 300, 400, 500, 600, ], }, { headingFontWeights: [1000, 200, 300, 400, 500, 600], invalidFontWeight: 1000, invalidFontWeightConfig: "h1FontWeight", expectedWeights: [ baseTheme.emotion.fontWeights.h1FontWeight, 200, 300, 400, 500, 600, ], }, { headingFontWeights: [400.5, 200, 300, 400, 500, 600], invalidFontWeight: 400.5, invalidFontWeightConfig: "h1FontWeight", expectedWeights: [ baseTheme.emotion.fontWeights.h1FontWeight, 200, 300, 400, 500, 600, ], }, { headingFontWeights: [200, 150, 300, 400, 500, 600], invalidFontWeight: 150, invalidFontWeightConfig: "h2FontWeight", expectedWeights: [ 200, baseTheme.emotion.fontWeights.h2FontWeight, 300, 400, 500, 600, ], }, { headingFontWeights: [200, 1000, 300, 400, 500, 600], invalidFontWeight: 1000, invalidFontWeightConfig: "h2FontWeight", expectedWeights: [ 200, baseTheme.emotion.fontWeights.h2FontWeight, 300, 400, 500, 600, ], }, { headingFontWeights: [200, 400.5, 300, 400, 500, 600], invalidFontWeight: 400.5, invalidFontWeightConfig: "h2FontWeight", expectedWeights: [ 200, baseTheme.emotion.fontWeights.h2FontWeight, 300, 400, 500, 600, ], }, { headingFontWeights: [200, 300, 150, 400, 500, 600], invalidFontWeight: 150, invalidFontWeightConfig: "h3FontWeight", expectedWeights: [ 200, 300, baseTheme.emotion.fontWeights.h3FontWeight, 400, 500, 600, ], }, { headingFontWeights: [200, 300, 1000, 400, 500, 600], invalidFontWeight: 1000, invalidFontWeightConfig: "h3FontWeight", expectedWeights: [ 200, 300, baseTheme.emotion.fontWeights.h3FontWeight, 400, 500, 600, ], }, { headingFontWeights: [200, 300, 400.5, 400, 500, 600], invalidFontWeight: 400.5, invalidFontWeightConfig: "h3FontWeight", expectedWeights: [ 200, 300, baseTheme.emotion.fontWeights.h3FontWeight, 400, 500, 600, ], }, { headingFontWeights: [200, 300, 400, 150, 500, 600], invalidFontWeight: 150, invalidFontWeightConfig: "h4FontWeight", expectedWeights: [ 200, 300, 400, baseTheme.emotion.fontWeights.h4FontWeight, 500, 600, ], }, { headingFontWeights: [200, 300, 400, 1000, 500, 600], invalidFontWeight: 1000, invalidFontWeightConfig: "h4FontWeight", expectedWeights: [ 200, 300, 400, baseTheme.emotion.fontWeights.h4FontWeight, 500, 600, ], }, { headingFontWeights: [200, 300, 400, 400.5, 500, 600], invalidFontWeight: 400.5, invalidFontWeightConfig: "h4FontWeight", expectedWeights: [ 200, 300, 400, baseTheme.emotion.fontWeights.h4FontWeight, 500, 600, ], }, { headingFontWeights: [200, 300, 400, 500, 150, 600], invalidFontWeight: 150, invalidFontWeightConfig: "h5FontWeight", expectedWeights: [ 200, 300, 400, 500, baseTheme.emotion.fontWeights.h5FontWeight, 600, ], }, { headingFontWeights: [200, 300, 400, 500, 1000, 600], invalidFontWeight: 1000, invalidFontWeightConfig: "h5FontWeight", expectedWeights: [ 200, 300, 400, 500, baseTheme.emotion.fontWeights.h5FontWeight, 600, ], }, { headingFontWeights: [200, 300, 400, 500, 400.5, 600], invalidFontWeight: 400.5, invalidFontWeightConfig: "h5FontWeight", expectedWeights: [ 200, 300, 400, 500, baseTheme.emotion.fontWeights.h5FontWeight, 600, ], }, { headingFontWeights: [200, 300, 400, 500, 600, 150], invalidFontWeight: 150, invalidFontWeightConfig: "h6FontWeight", expectedWeights: [ 200, 300, 400, 500, 600, baseTheme.emotion.fontWeights.h6FontWeight, ], }, { headingFontWeights: [200, 300, 400, 500, 600, 1000], invalidFontWeight: 1000, invalidFontWeightConfig: "h6FontWeight", expectedWeights: [ 200, 300, 400, 500, 600, baseTheme.emotion.fontWeights.h6FontWeight, ], }, { headingFontWeights: [200, 300, 400, 500, 600, 400.5], invalidFontWeight: 400.5, invalidFontWeightConfig: "h6FontWeight", expectedWeights: [ 200, 300, 400, 500, 600, baseTheme.emotion.fontWeights.h6FontWeight, ], }, ])( "logs a warning and falls back to default font weights if headingFontWeights is invalid ($invalidFontWeightConfig: $invalidFontWeight)", ({ headingFontWeights, invalidFontWeight, invalidFontWeightConfig, expectedWeights, }) => { const logWarningSpy = vi.spyOn(LOG, "warn") const themeInput: Partial<CustomThemeConfig> = { headingFontWeights, } const theme = createEmotionTheme(themeInput) expect(logWarningSpy).toHaveBeenCalledWith( `Invalid ${invalidFontWeightConfig} in headingFontWeights: ${invalidFontWeight} in theme. The ${invalidFontWeightConfig} in headingFontWeights must be an integer 100-900, and an increment of 100. Falling back to default font weight.` ) // Check that the heading font weights are set correctly expect(theme.fontWeights.h1FontWeight).toBe(expectedWeights[0]) expect(theme.fontWeights.h2FontWeight).toBe(expectedWeights[1]) expect(theme.fontWeights.h3FontWeight).toBe(expectedWeights[2]) expect(theme.fontWeights.h4FontWeight).toBe(expectedWeights[3]) expect(theme.fontWeights.h5FontWeight).toBe(expectedWeights[4]) expect(theme.fontWeights.h6FontWeight).toBe(expectedWeights[5]) } ) // == Metric value font properties == it.each([ // Valid metricValueFontSize values: rem, px, or plain number (treated as px) ["3rem", "3rem"], ["3REM", "3rem"], ["2.25rem", "2.25rem"], ["48px", "48px"], ["48PX", "48px"], ["48", "48px"], ])( "correctly applies metricValueFontSize '%s'", (metricValueFontSize, expectedFontSize) => { const themeInput: Partial<CustomThemeConfig> = { metricValueFontSize, } const theme = createEmotionTheme(themeInput) expect(theme.metricValueFontSize).toBe(expectedFontSize) } ) it("does not set metricValueFontSize if not configured", () => { const theme = createEmotionTheme({}) expect(theme.metricValueFontSize).toBeUndefined() }) it.each([ // Invalid format values "invalid", "rem", "px", " ", ])( "logs a warning and does not set metricValueFontSize for invalid value '%s'", metricValueFontSize => { const logWarningSpy = vi.spyOn(LOG, "warn") const themeInput: Partial<CustomThemeConfig> = { metricValueFontSize, } const theme = createEmotionTheme(themeInput) expect(logWarningSpy).toHaveBeenCalledWith( `Invalid size passed for metricValueFontSize in theme: ${metricValueFontSize}. Falling back to default metricValueFontSize.` ) expect(theme.metricValueFontSize).toBeUndefined() } ) it.each([ // Zero with unit (caught by parseFontSize due to falsy 0) "0px", "0rem", ])( "logs a warning and does not set metricValueFontSize for zero value '%s'", metricValueFontSize => { const logWarningSpy = vi.spyOn(LOG, "warn") const themeInput: Partial<CustomThemeConfig> = { metricValueFontSize, } const theme = createEmotionTheme(themeInput) expect(logWarningSpy).toHaveBeenCalledWith( `Invalid size passed for metricValueFontSize in theme: ${metricValueFontSize}. Falling back to default metricValueFontSize.` ) expect(theme.metricValueFontSize).toBeUndefined() } ) it.each([ // Zero without unit, negative values (caught by additional validation) "0", "-10", "-10px", "-10rem", ])( "logs a warning and does not set metricValueFontSize for zero/negative value '%s'", metricValueFontSize => { const logWarningSpy = vi.spyOn(LOG, "warn") const themeInput: Partial<CustomThemeConfig> = { metricValueFontSize, } const theme = createEmotionTheme(themeInput) expect(logWarningSpy).toHaveBeenCalledWith( `Invalid metricValueFontSize: ${metricValueFontSize} in theme. The metricValueFontSize must be greater than 0. Falling back to default metricValueFontSize.` ) expect(theme.metricValueFontSize).toBeUndefined() } ) it("uses metricValueFontWeight when configured", () => { const themeInput: Partial<CustomThemeConfig> = { metricValueFontWeight: 600, } const theme = createEmotionTheme(themeInput) expect(theme.metricValueFontWeight).toBe(600) }) it("does not set metricValueFontWeight if not configured", () => { const theme = createEmotionTheme({}) expect(theme.metricValueFontWeight).toBeUndefined() }) it.each([50, 950, -100])( "logs a warning and does not set metricValueFontWeight if value is out of range: %s", metricValueFontWeight => { const logWarningSpy = vi.spyOn(LOG, "warn") const themeInput: Partial<CustomThemeConfig> = { metricValueFontWeight, } const theme = createEmotionTheme(themeInput) expect(logWarningSpy).toHaveBeenCalledWith( `Invalid metricValueFontWeight: ${metricValueFontWeight}. Must be between 100 and 900.` ) expect(theme.metricValueFontWeight).toBeUndefined() } ) // == Theme font properties == it("uses bodyFont when configured", () => { const themeInput: Partial<CustomThemeConfig> = { bodyFont: "Body Font Test", } const theme = createEmotionTheme(themeInput) expect(theme.genericFonts.bodyFont).toBe( 'Body Font Test, "Source Sans", sans-serif' ) }) it("uses codeFont when configured", () => { const themeInput: Partial<CustomThemeConfig> = { codeFont: "Code Font Test", } const theme = createEmotionTheme(themeInput) expect(theme.genericFonts.codeFont).toBe( 'Code Font Test, "Source Code Pro", monospace' ) }) it("uses headingFont when configured", () => { const themeInput: Partial<CustomThemeConfig> = { headingFont: "Heading Font Test", } const theme = createEmotionTheme(themeInput) expect(theme.genericFonts.headingFont).toBe( 'Heading Font Test, "Source Sans", sans-serif' ) }) it("uses bodyFont for headingFont when headingFont is not configured", () => { const themeInput: Partial<CustomThemeConfig> = { bodyFont: "monospace", // headingFont is intentionally not set } const theme = createEmotionTheme(themeInput) expect(theme.genericFonts.bodyFont).toBe(theme.fonts.monospace) expect(theme.genericFonts.headingFont).toBe(theme.fonts.monospace) }) }) describe("toThemeInput", () => { it("converts from emotion theme to what a custom component expects", () => { const { colors } = lightTheme.emotion expect(toThemeInput(lightTheme.emotion)).toEqual({ primaryColor: colors.primary, bodyFont: `"Source Sans", sans-serif`, backgroundColor: colors.bgColor, secondaryBackgroundColor: colors.secondaryBg, textColor: colors.bodyText, }) }) }) describe("bgColorToBaseString", () => { it("returns 'light' if passed undefined", () => { expect(bgColorToBaseString(undefined)).toBe("light") }) it("returns 'light' for a light background color", () => { expect(bgColorToBaseString("#FFFFFF")).toBe("light") }) it("returns 'dark' for a dark background color", () => { expect(bgColorToBaseString("#000000")).toBe("dark") }) }) describe("hasLightBackgroundColor", () => { const testCases = [ { description: "works for default light theme", theme: lightTheme, expectedResult: true, }, { description: "works for default dark theme", theme: darkTheme, expectedResult: false, }, { description: "works for custom light theme", theme: createTheme( CUSTOM_THEME_NAME, new CustomThemeConfig({ backgroundColor: "yellow" }) ), expectedResult: true, }, { description: "works for custom dark theme", theme: createTheme( CUSTOM_THEME_NAME, new CustomThemeConfig({ backgroundColor: "navy" }) ), expectedResult: false, }, ] testCases.forEach(({ description, theme, expectedResult }) => { it(`${description}`, () => { expect(hasLightBackgroundColor(theme.emotion)).toBe(expectedResult) }) }) }) describe("theme overrides", () => { beforeEach(() => { vi.resetModules() window.__streamlit = undefined }) afterEach(() => { vi.resetModules() window.__streamlit = undefined }) it("honors the window variables set", async () => { window.__streamlit = { LIGHT_THEME: { primaryColor: "purple", }, DARK_THEME: { primaryColor: "yellow", }, } const module = await import("./utils") // Ensure we are not working with the same object expect(module.getMergedLightTheme()).not.toEqual(lightTheme) expect(module.getMergedDarkTheme()).not.toEqual(darkTheme) expect(module.getMergedLightTheme().emotion.colors.primary).toEqual( "purple" ) expect(module.getMergedDarkTheme().emotion.colors.primary).toEqual( "yellow" ) }) it("maintains original theme if no global themes are specified", async () => { const module = await import("./utils") expect(module.getMergedLightTheme()).toEqual(lightTheme) expect(module.getMergedDarkTheme()).toEqual(darkTheme) }) }) describe("parseFont", () => { it.each([ // Test standard font mappings ["sans-serif", '"Source Sans", sans-serif'], ["Sans-Serif", '"Source Sans", sans-serif'], // Case insensitive ["SANS-SERIF", '"Source Sans", sans-serif'], // All caps ["sans serif", '"Source Sans", sans-serif'], // With space ["serif", '"Source Serif", serif'], ["monospace", '"Source Code Pro", monospace'], // Test fonts that aren't in the map (should always append Streamlit default as fallback) ["Arial", 'Arial, "Source Sans", sans-serif'], ["Helvetica", 'Helvetica, "Source Sans", sans-serif'], ["Times New Roman", 'Times New Roman, "Source Sans", sans-serif'], ["Comic Sans MS", 'Comic Sans MS, "Source Sans", sans-serif'], // Empty string should return just the fallback font ["", '"Source Sans", sans-serif'], // Whitespace-only string should also return just the fallback font [" ", '"Source Sans", sans-serif'], // Test fonts that already have fallbacks (should still append Streamlit default as final fallback) [ '"Roboto", Arial, sans-serif', '"Roboto", Arial, sans-serif, "Source Sans", sans-serif', ], [ "Arial, Helvetica, sans-serif", 'Arial, Helvetica, sans-serif, "Source Sans", sans-serif', ], ])("correctly maps '%s' to '%s'", (input, expected) => { expect(parseFont(input)).toBe(expected) }) it("allows custom fallback font", () => { expect(parseFont("Arial", '"Source Code Pro", monospace')).toBe( 'Arial, "Source Code Pro", monospace' ) }) it("appends fallback even to fonts that already have commas", () => { expect(parseFont('"My Font", Arial')).toBe( '"My Font", Arial, "Source Sans", sans-serif' ) }) }) describe("Font weight configuration coverage", () => { it("ensures all font weights from typography.ts are handled in setFontWeights", () => { // Import the default font weights from typography const { fontWeights: defaultFontWeights } = lightTheme.emotion // List of font weights that should NOT be affected by baseFontWeight const UNAFFECTED_BY_BASE_WEIGHT = [ "h1FontWeight", "h2FontWeight", "h3FontWeight", "h4FontWeight", "h5FontWeight", "h6FontWeight", ] // List of font weights that SHOULD be calculated based on baseFontWeight const AFFECTED_BY_BASE_WEIGHT = ["normal", "semiBold", "bold", "extrabold"] // Get all font weight keys from the default theme const allFontWeightKeys = Object.keys(defaultFontWeights) // Filter out special cases const fontWeightsToCheck = allFontWeightKeys.filter( key => !UNAFFECTED_BY_BASE_WEIGHT.includes(key) && key !== "code" && // code font weights are handled separately key !== "codeBold" && key !== "codeExtraBold" ) // Verify our expected list matches reality const missingFromExpected = fontWeightsToCheck.filter( key => !AFFECTED_BY_BASE_WEIGHT.includes(key) ) if (missingFromExpected.length > 0) { throw new Error( `New font weight(s) detected in typography.ts that are not handled in utils.ts setFontWeights function:\n` + ` ${missingFromExpected.join(", ")}\n\n` + `When adding new font weights, you must:\n` + ` 1. Update the setFontWeights function in utils.ts to calculate the new weight based on baseFontWeight\n` + ` 2. Add the new font weight to the AFFECTED_BY_BASE_WEIGHT array in this test\n` + ` 3. Add test cases to verify the calculation logic\n\n` + `Example: If you added 'medium', you might set it to baseFontWeight + 50` ) } // Test that baseFontWeight actually affects the expected weights const testTheme = createEmotionTheme( { baseFontWeight: 300 } as Partial<ICustomThemeConfig>, lightTheme ) AFFECTED_BY_BASE_WEIGHT.forEach(weightKey => { const typedKey = weightKey as keyof typeof testTheme.fontWeights expect(testTheme.fontWeights[typedKey]).not.toBe( defaultFontWeights[typedKey] ) }) // Verify unaffected weights remain unchanged UNAFFECTED_BY_BASE_WEIGHT.forEach(weightKey => { const typedKey = weightKey as keyof typeof testTheme.fontWeights expect(testTheme.fontWeights[typedKey]).toBe( defaultFontWeights[typedKey] ) }) }) }) describe("sortThemeInputKeys", () => { it("sorts basic theme input keys", () => { const themeInput = new CustomThemeConfig({ primaryColor: "blue", light: { primaryColor: "red", }, }) const sorted = sortThemeInputKeys(themeInput) // sortThemeInputKeys should produce consistent JSON regardless of key order const sorted2 = sortThemeInputKeys(themeInput) expect(JSON.stringify(sorted)).toBe(JSON.stringify(sorted2)) // Verify the primary keys we care about are sorted const keys = Object.keys(sorted as Record<string, unknown>) const lightIndex = keys.indexOf("light") const primaryColorIndex = keys.indexOf("primaryColor") // 'light' should come before 'primaryColor' alphabetically expect(lightIndex).toBeLessThan(primaryColorIndex) }) it("handles deeply nested objects", () => { const input = { z: "last", a: "first", nested: { z: "nested-last", a: "nested-first", deep: { z: "deep-last", a: "deep-first", }, }, } const sorted = sortThemeInputKeys(input) as Record<string, unknown> const keys = Object.keys(sorted) expect(keys[0]).toBe("a") expect(keys[1]).toBe("nested") expect(keys[2]).toBe("z") const nested = sorted.nested as Record<string, unknown> const nestedKeys = Object.keys(nested) expect(nestedKeys[0]).toBe("a") expect(nestedKeys[1]).toBe("deep") expect(nestedKeys[2]).toBe("z") const deep = nested.deep as Record<string, unknown> const deepKeys = Object.keys(deep) expect(deepKeys[0]).toBe("a") expect(deepKeys[1]).toBe("z") }) it("handles arrays of objects", () => { const input = { colors: [ { z: 1, a: 2 }, { z: 3, a: 4 }, ], } const sorted = sortThemeInputKeys(input) as { colors: Array<Record<string, number>> } expect(Object.keys(sorted.colors[0])).toEqual(["a", "z"]) expect(Object.keys(sorted.colors[1])).toEqual(["a", "z"]) expect(sorted.colors[0]).toEqual({ a: 2, z: 1 }) expect(sorted.colors[1]).toEqual({ a: 4, z: 3 }) }) it("handles null and undefined values", () => { const input = { b: null, a: undefined, c: "value" } const sorted = sortThemeInputKeys(input) expect(sorted).toEqual({ a: undefined, b: null, c: "value" }) // Verify order const keys = Object.keys(sorted as Record<string, unknown>) expect(keys).toEqual(["a", "b", "c"]) }) it("handles mixed types", () => { const input = { string: "text", number: 42, boolean: true, array: [1, 2, 3], object: { b: 2, a: 1 }, nullValue: null, } const sorted = sortThemeInputKeys(input) as Record<string, unknown> const keys = Object.keys(sorted) expect(keys).toEqual([ "array", "boolean", "nullValue", "number", "object", "string", ]) // Verify nested object is also sorted const nestedObj = sorted.object as Record<string, number> expect(Object.keys(nestedObj)).toEqual(["a", "b"]) }) it("produces consistent hashes for same content with different key orders", () => { const input1 = { z: 1, y: 2, x: { c: 3, b: 4, a: 5 } } const input2 = { x: { a: 5, b: 4, c: 3 }, y: 2, z: 1 } const sorted1 = JSON.stringify(sortThemeInputKeys(input1)) const sorted2 = JSON.stringify(sortThemeInputKeys(input2)) expect(sorted1).toBe(sorted2) }) it("handles empty objects and arrays", () => { const input = { emptyObject: {}, emptyArray: [], nested: { alsoEmpty: {}, }, } const sorted = sortThemeInputKeys(input) expect(sorted).toEqual({ emptyArray: [], emptyObject: {}, nested: { alsoEmpty: {}, }, }) }) it("handles arrays of primitives", () => { const input = { numbers: [3, 1, 2], strings: ["c", "a", "b"], mixed: [3, "a", null, true], } const sorted = sortThemeInputKeys(input) as typeof input // Arrays should maintain their order (only objects within arrays get sorted) expect(sorted.numbers).toEqual([3, 1, 2]) expect(sorted.strings).toEqual(["c", "a", "b"]) expect(sorted.mixed).toEqual([3, "a", null, true]) }) it("handles complex nested theme config structure", () => { const input = new CustomThemeConfig({ primaryColor: "blue", backgroundColor: "white", sidebar: { backgroundColor: "gray", primaryColor: "red", }, light: { primaryColor: "lightblue", sidebar: { backgroundColor: "lightgray", }, }, dark: { primaryColor: "darkblue", backgroundColor: "black", }, }) const sorted = sortThemeInputKeys(input) const sortedStr = JSON.stringify(sorted) // Verify the JSON representation contains all properties expect(sortedStr).toContain("backgroundColor") expect(sortedStr).toContain("primaryColor") expect(sortedStr).toContain("sidebar") expect(sortedStr).toContain("light") expect(sortedStr).toContain("dark") // Verify consistent serialization const sorted2 = sortThemeInputKeys(input) expect(JSON.stringify(sorted)).toBe(JSON.stringify(sorted2)) }) }) describe("Custom theme creation", () => { describe("hasThemeSectionConfigs", () => { it("returns true if there are any theme section configs - light", () => { const themeInput = new CustomThemeConfig({ primaryColor: "blue", light: { primaryColor: "red", }, }) expect(hasThemeSectionConfigs(themeInput.light)).toBe(true) }) it("returns true if there are any theme section configs - dark", () => { const themeInput = new CustomThemeConfig({ primaryColor: "blue", dark: { primaryColor: "red", }, }) expect(hasThemeSectionConfigs(themeInput.dark)).toBe(true) }) it("returns false if there are no theme section configs", () => { const themeInput = new CustomThemeConfig({ primaryColor: "blue", light: {}, dark: {}, }) expect(hasThemeSectionConfigs(themeInput.light)).toBe(false) expect(hasThemeSectionConfigs(themeInput.dark)).toBe(false) }) it("returns true for nested theme section configs - light", () => { const themeInput = new CustomThemeConfig({ primaryColor: "blue", light: { sidebar: { primaryColor: "green", }, }, }) expect(hasThemeSectionConfigs(themeInput.light)).toBe(true) }) it("returns true for nested theme section configs - dark", () => { const themeInput = new CustomThemeConfig({ primaryColor: "blue", dark: { sidebar: { primaryColor: "green", }, }, }) expect(hasThemeSectionConfigs(themeInput.dark)).toBe(true) }) it("returns false for default values - empty arrays", () => { const themeInput = new CustomThemeConfig({ primaryColor: "blue", light: { headingFontSizes: [], headingFontWeights: [], sidebar: { headingFontSizes: [], headingFontWeights: [], }, }, dark: { headingFontSizes: [], headingFontWeights: [], sidebar: { headingFontSizes: [], headingFontWeights: [], }, }, }) expect(hasThemeSectionConfigs(themeInput.light)).toBe(false) expect(hasThemeSectionConfigs(themeInput.dark)).toBe(false) }) }) describe("handleSectionInheritance", () => { it("correctly merges light section configs with base configs", () => { const themeInput = new CustomThemeConfig({ primaryColor: "blue", backgroundColor: "gray", light: { primaryColor: "lightblue", textColor: "black", }, }) const result = handleSectionInheritance(themeInput, "light") // Should merge light section with base expect(result.primaryColor).toBe("lightblue") expect(result.textColor).toBe("black") expect(result.backgroundColor).toBe("gray") // Inherited from base expect(result.base).toBe(CustomThemeConfig.BaseTheme.LIGHT) // Light and dark sections should be removed expect(result.light).toBeUndefined() expect(result.dark).toBeUndefined() }) it("correctly merges dark section configs with base configs", () => { const themeInput = new CustomThemeConfig({ primaryColor: "red", backgroundColor: "gray", dark: { primaryColor: "darkred", textColor: "white", }, }) const result = handleSectionInheritance(themeInput, "dark") // Should merge dark section with base expect(result.primaryColor).toBe("darkred") expect(result.textColor).toBe("white") expect(result.backgroundColor).toBe("gray") // Inherited from base expect(result.base).toBe(CustomThemeConfig.BaseTheme.DARK) // Light and dark sections should be removed expect(result.light).toBeUndefined() expect(result.dark).toBeUndefined() }) it("handles nested sidebar inheritance for light theme", () => { const themeInput = new CustomThemeConfig({ primaryColor: "blue", sidebar: { primaryColor: "gray", textColor: "black", }, light: { primaryColor: "lightblue", sidebar: { backgroundColor: "lightgray", }, }, }) const result = handleSectionInheritance(themeInput, "light") // Should use light theme overrides expect(result.primaryColor).toBe("lightblue") // Sidebar should merge: theme.sidebar + theme.light.sidebar expect(result.sidebar?.primaryColor).toBe("gray") // From theme.sidebar expect(result.sidebar?.textColor).toBe("black") // From theme.sidebar expect(result.sidebar?.backgroundColor).toBe("lightgray") // From theme.light.sidebar // Sections should be removed expect(result.light).toBeUndefined() expect(result.dark).toBeUndefined() }) it("handles nested sidebar inheritance for dark theme", () => { const themeInput = new CustomThemeConfig({ primaryColor: "red", sidebar: { primaryColor: "gray", textColor: "white", }, dark: { primaryColor: "darkred", sidebar: { backgroundColor: "darkgray", }, }, }) const result = handleSectionInheritance(themeInput, "dark") // Should use dark theme overrides expect(result.primaryColor).toBe("darkred") // Sidebar should merge: theme.sidebar + theme.dark.sidebar expect(result.sidebar?.primaryColor).toBe("gray") // From theme.sidebar expect(result.sidebar?.textColor).toBe("white") // From theme.sidebar expect(result.sidebar?.backgroundColor).toBe("darkgray") // From theme.dark.sidebar // Sections should be removed expect(result.light).toBeUndefined() expect(result.dark).toBeUndefined() }) it("respects full precedence chain: theme < theme.dark < theme.sidebar < theme.dark.sidebar", () => { const themeInput = new CustomThemeConfig({ primaryColor: "base-primary", textColor: "base-text", sidebar: { primaryColor: "sidebar-primary", textColor: "sidebar-text", backgroundColor: "sidebar-bg", }, dark: { primaryColor: "dark-primary", textColor: "dark-text", sidebar: { primaryColor: "dark-sidebar-primary", backgroundColor: "dark-sidebar-bg", }, }, }) const result = handleSectionInheritance(themeInput, "dark") // Main theme properties: theme < theme.dark expect(result.primaryColor).toBe("dark-primary") // theme.dark wins expect(result.textColor).toBe("dark-text") // theme.dark wins // Sidebar properties with full precedence: // theme.sidebar.primaryColor="sidebar-primary" < theme.dark.sidebar.primaryColor="dark-sidebar-primary" expect(result.sidebar?.primaryColor).toBe("dark-sidebar-primary") // theme.dark.sidebar wins // theme.sidebar.backgroundColor="sidebar-bg" < theme.dark.sidebar.backgroundColor="dark-sidebar-bg" expect(result.sidebar?.backgroundColor).toBe("dark-sidebar-bg") // theme.dark.sidebar wins // theme.sidebar.textColor="sidebar-text" (no override in theme.dark.sidebar) expect(result.sidebar?.textColor).toBe("sidebar-text") // theme.sidebar wins (no override) }) }) describe("createCustomThemes", () => { it("returns 1 custom theme when there are no theme section configs", () => { const themeInput = new CustomThemeConfig({ primaryColor: "blue", backgroundColor: "white", }) const customThemes = createCustomThemes(themeInput) expect(customThemes).toHaveLength(1) expect(customThemes[0].name).toBe(CUSTOM_THEME_NAME) expect(customThemes[0].emotion.colors.primary).toBe("blue") expect(customThemes[0].emotion.colors.bgColor).toBe("white") }) it("returns 1 custom theme based on dark base when no section configs", () => { const themeInput = new CustomThemeConfig({ base: CustomThemeConfig.BaseTheme.DARK, primaryColor: "purple", }) const customThemes = createCustomThemes(themeInput) expect(customThemes).toHaveLength(1) expect(customThemes[0].name).toBe(CUSTOM_THEME_NAME) expect(customThemes[0].emotion.colors.primary).toBe("purple") // Should inherit dark theme auxiliary colors expect(customThemes[0].emotion.colors.yellowTextColor).toBe( darkTheme.emotion.colors.yellowTextColor ) }) it("returns 3 custom themes when light section configs are set", () => { const themeInput = new CustomThemeConfig({ primaryColor: "blue", light: { primaryColor: "lightblue", }, }) const customThemes = createCustomThemes(themeInput) // Expect 3 themes: Custom Theme Light, Custom Theme Dark, Custom Theme Auto expect(customThemes).toHaveLength(3) expect(customThemes[0].name).toBe(CUSTOM_THEME_LIGHT_NAME) expect(customThemes[1].name).toBe(CUSTOM_THEME_DARK_NAME) expect(customThemes[2].name).toBe(CUSTOM_THEME_AUTO_NAME) // Light theme should use light section override expect(customThemes[0].emotion.colors.primary).toBe("lightblue") // Dark theme should use base config expect(customThemes[1].emotion.colors.primary).toBe("blue") }) it("returns 3 custom themes when dark section configs are set", () => { const themeInput = new CustomThemeConfig({ primaryColor: "green", dark: { primaryColor: "darkgreen", }, }) const customThemes = createCustomThemes(themeInput) // Expect 3 themes: Custom Theme Light, Custom Theme Dark, Custom Theme Auto expect(customThemes).toHaveLength(3) expect(customThemes[0].name).toBe(CUSTOM_THEME_LIGHT_NAME) expect(customThemes[1].name).toBe(CUSTOM_THEME_DARK_NAME) expect(customThemes[2].name).toBe(CUSTOM_THEME_AUTO_NAME) // Light theme should use base config expect(customThemes[0].emotion.colors.primary).toBe("green") // Dark theme should use dark section override expect(customThemes[1].emotion.colors.primary).toBe("darkgreen") }) it("returns 3 custom themes when both light and dark section configs are set", () => { const themeInput = new CustomThemeConfig({ primaryColor: "yellow", light: { primaryColor: "lightyellow", backgroundColor: "white", }, dark: { primaryColor: "gold", backgroundColor: "black", }, }) const customThemes = createCustomThemes(themeInput) // Expect 3 themes: Custom Theme Light, Custom Theme Dark, Custom Theme Auto expect(customThemes).toHaveLength(3) // Light theme expect(customThemes[0].name).toBe(CUSTOM_THEME_LIGHT_NAME) expect(customThemes[0].emotion.colors.primary).toBe("lightyellow") expect(customThemes[0].emotion.colors.bgColor).toBe("white") // Dark theme expect(customThemes[1].name).toBe(CUSTOM_THEME_DARK_NAME) expect(customThemes[1].emotion.colors.primary).toBe("gold") expect(customThemes[1].emotion.colors.bgColor).toBe("black") }) it("handles nested sidebar configs in light section", () => { const themeInput = new CustomThemeConfig({ primaryColor: "blue", sidebar: { primaryColor: "gray", textColor: "white", }, light: { sidebar: { backgroundColor: "lightgray", }, }, }) const customThemes = createCustomThemes(themeInput) // Expect 3 themes: Custom Theme Light, Custom Theme Dark, Custom Theme Auto expect(customThemes).toHaveLength(3) // Light theme sidebar should merge: theme.sidebar + theme.light.sidebar expect(customThemes[0].themeInput?.sidebar?.primaryColor).toBe("gray") // From theme.sidebar expect(customThemes[0].themeInput?.sidebar?.textColor).toBe("white") // From theme.sidebar expect(customThemes[0].themeInput?.sidebar?.backgroundColor).toBe( "lightgray" ) // From theme.light.sidebar // Dark theme sidebar should only have base sidebar config (no dark.sidebar override) expect(customThemes[1].themeInput?.sidebar?.primaryColor).toBe("gray") expect(customThemes[1].themeInput?.sidebar?.textColor).toBe("white") expect( customThemes[1].themeInput?.sidebar?.backgroundColor ).toBeUndefined() }) it("handles nested sidebar configs in dark section", () => { const themeInput = new CustomThemeConfig({ primaryColor: "red", sidebar: { primaryColor: "gray", textColor: "black", }, dark: { sidebar: { backgroundColor: "darkgray", }, }, }) const customThemes = createCustomThemes(themeInput) // Expect 3 themes: Custom Theme Light, Custom Theme Dark, Custom Theme Auto expect(customThemes).toHaveLength(3) // Light theme sidebar should only have base sidebar config (no light.sidebar override) expect(customThemes[0].themeInput?.sidebar?.primaryColor).toBe("gray") expect(customThemes[0].themeInput?.sidebar?.textColor).toBe("black") expect( customThemes[0].themeInput?.sidebar?.backgroundColor ).toBeUndefined() // Dark theme sidebar should merge: theme.sidebar + theme.dark.sidebar expect(customThemes[1].themeInput?.sidebar?.primaryColor).toBe("gray") // From theme.sidebar expect(customThemes[1].themeInput?.sidebar?.textColor).toBe("black") // From theme.sidebar expect(customThemes[1].themeInput?.sidebar?.backgroundColor).toBe( "darkgray" ) // From theme.dark.sidebar }) it("treats empty light/dark sections as no section configs", () => { const themeInput = new CustomThemeConfig({ primaryColor: "orange", light: {}, dark: {}, }) const customThemes = createCustomThemes(themeInput) // Should return 1 theme since empty sections don't count as configs expect(customThemes).toHaveLength(1) expect(customThemes[0].name).toBe(CUSTOM_THEME_NAME) }) it("ignores empty arrays in section configs", () => { const themeInput = new CustomThemeConfig({ primaryColor: "pink", light: { headingFontSizes: [], headingFontWeights: [], }, }) const customThemes = createCustomThemes(themeInput) // Empty arrays don't count as configs, so should return 1 theme expect(customThemes).toHaveLength(1) expect(customThemes[0].name).toBe(CUSTOM_THEME_NAME) }) it("sets auto theme to light when system preference is light", () => { // Mock the system preference return value (light) Object.defineProperty(window, "matchMedia", { writable: true, value: vi.fn().mockImplementation(query => ({ matches: query === "(prefers-color-scheme: light)", // Returns true for light })), }) const themeInput = new CustomThemeConfig({ primaryColor: "yellow", light: { primaryColor: "lightyellow", backgroundColor: "white", }, dark: { primaryColor: "gold", backgroundColor: "black", }, }) const customThemes = createCustomThemes(themeInput) // Expect 3 themes: Custom Theme Light, Custom Theme Dark, Custom Theme Auto expect(customThemes).toHaveLength(3) expect(customThemes[2].name).toBe(CUSTOM_THEME_AUTO_NAME) // Auto theme should be based the same as the custom light theme expect(customThemes[2].emotion.colors.primary).toBe("lightyellow") expect(customThemes[2].emotion.colors.bgColor).toBe("white") }) it("sets auto theme to dark when system preference is dark", () => { // Mock the system preference return value (dark) Object.defineProperty(window, "matchMedia", { writable: true, value: vi.fn().mockImplementation(query => ({ matches: query === "(prefers-color-scheme: dark)", // Returns true for dark })), }) const themeInput = new CustomThemeConfig({ primaryColor: "yellow", light: { primaryColor: "lightyellow", backgroundColor: "white", }, dark: { primaryColor: "gold", backgroundColor: "black", }, }) const customThemes = createCustomThemes(themeInput) // Expect 3 themes: Custom Theme Light, Custom Theme Dark, Custom Theme Auto expect(customThemes).toHaveLength(3) expect(customThemes[2].name).toBe(CUSTOM_THEME_AUTO_NAME) // Auto theme should be based the same as the custom dark theme expect(customThemes[2].emotion.colors.primary).toBe("gold") expect(customThemes[2].emotion.colors.bgColor).toBe("black") }) }) }) describe("Sidebar theme creation", () => { describe("createSidebarTheme", () => { it("creates sidebar theme with swapped background colors by default", () => { const mainTheme = createTheme(CUSTOM_THEME_NAME, { backgroundColor: "white", secondaryBackgroundColor: "lightgray", }) const sidebarTheme = createSidebarTheme(mainTheme) // Sidebar background should be main theme's secondary background expect(sidebarTheme.emotion.colors.bgColor).toBe("lightgray") // Sidebar secondary background should be main theme's background expect(sidebarTheme.emotion.colors.secondaryBg).toBe("white") expect(sidebarTheme.emotion.inSidebar).toBe(true) }) it("uses configured sidebar backgroundColor when provided", () => { const mainTheme = createTheme(CUSTOM_THEME_NAME, { backgroundColor: "white", secondaryBackgroundColor: "lightgray", sidebar: { backgroundColor: "darkblue", }, }) const sidebarTheme = createSidebarTheme(mainTheme) // Should use configured sidebar background expect(sidebarTheme.emotion.colors.bgColor).toBe("darkblue") // Secondary should still swap to main background expect(sidebarTheme.emotion.colors.secondaryBg).toBe("white") }) it("uses configured sidebar secondaryBackgroundColor when provided", () => { const mainTheme = createTheme(CUSTOM_THEME_NAME, { backgroundColor: "white", secondaryBackgroundColor: "lightgray", sidebar: { secondaryBackgroundColor: "blue", }, }) const sidebarTheme = createSidebarTheme(mainTheme) // Background should still swap to main secondary expect(sidebarTheme.emotion.colors.bgColor).toBe("lightgray") // Should use configured sidebar secondary background expect(sidebarTheme.emotion.colors.secondaryBg).toBe("blue") }) it("applies sidebar theme overrides", () => { const mainTheme = createTheme(CUSTOM_THEME_NAME, { primaryColor: "red", sidebar: { primaryColor: "blue", }, }) const sidebarTheme = createSidebarTheme(mainTheme) // Should apply sidebar primary color override expect(sidebarTheme.emotion.colors.primary).toBe("blue") }) it("uses default sidebar heading font sizes when not configured", () => { const mainTheme = createTheme(CUSTOM_THEME_NAME, { primaryColor: "red", }) const sidebarTheme = createSidebarTheme(mainTheme) // Should use default sidebar heading font sizes expect(sidebarTheme.emotion.fontSizes.h1FontSize).toBe("1.5rem") expect(sidebarTheme.emotion.fontSizes.h2FontSize).toBe("1.25rem") expect(sidebarTheme.emotion.fontSizes.h3FontSize).toBe("1.125rem") expect(sidebarTheme.emotion.fontSizes.h4FontSize).toBe("1rem") expect(sidebarTheme.emotion.fontSizes.h5FontSize).toBe("0.875rem") expect(sidebarTheme.emotion.fontSizes.h6FontSize).toBe("0.75rem") }) it("uses configured sidebar heading font sizes when provided", () => { const mainTheme = createTheme(CUSTOM_THEME_NAME, { primaryColor: "red", sidebar: { headingFontSizes: ["2rem", "1.75rem"], }, }) const sidebarTheme = createSidebarTheme(mainTheme) // Should override first two heading sizes expect(sidebarTheme.emotion.fontSizes.h1FontSize).toBe("2rem") expect(sidebarTheme.emotion.fontSizes.h2FontSize).toBe("1.75rem") // Rest should use defaults expect(sidebarTheme.emotion.fontSizes.h3FontSize).toBe("1.125rem") expect(sidebarTheme.emotion.fontSizes.h4FontSize).toBe("1rem") }) it("sets base theme to LIGHT when sidebar background is light", () => { const mainTheme = createTheme(CUSTOM_THEME_NAME, { backgroundColor: "black", sidebar: { backgroundColor: "white", }, }) const sidebarTheme = createSidebarTheme(mainTheme) // Should use light base for light sidebar background expect(sidebarTheme.emotion.colors.bgColor).toBe("white") // Should have light theme auxiliary colors expect(sidebarTheme.emotion.colors.yellowTextColor).toBe( lightTheme.emotion.colors.yellowTextColor ) }) it("sets base theme to DARK when sidebar background is dark", () => { const mainTheme = createTheme(CUSTOM_THEME_NAME, { backgroundColor: "white", sidebar: { backgroundColor: "black", }, }) const sidebarTheme = createSidebarTheme(mainTheme) // Should use dark base for dark sidebar background expect(sidebarTheme.emotion.colors.bgColor).toBe("black") // Should have dark theme auxiliary colors expect(sidebarTheme.emotion.colors.yellowTextColor).toBe( darkTheme.emotion.colors.yellowTextColor ) }) it("inherits main theme properties not overridden in sidebar", () => { const mainTheme = createTheme(CUSTOM_THEME_NAME, { primaryColor: "red", textColor: "navy", bodyFont: "monospace", sidebar: { primaryColor: "blue", // Only override primary color }, }) const sidebarTheme = createSidebarTheme(mainTheme) // Should use sidebar override expect(sidebarTheme.emotion.colors.primary).toBe("blue") // Should inherit from main theme expect(sidebarTheme.emotion.colors.bodyText).toBe("navy") expect(sidebarTheme.emotion.genericFonts.bodyFont).toBe( lightTheme.emotion.fonts.monospace ) }) it("removes empty arrays from sidebar configuration", () => { const mainTheme = createTheme(CUSTOM_THEME_NAME, { primaryColor: "red", headingFontSizes: ["2rem", "1.5rem"], sidebar: { headingFontSizes: [], // Empty array should be ignored }, }) const sidebarTheme = createSidebarTheme(mainTheme) // Should use default sidebar heading font sizes, not empty array expect(sidebarTheme.emotion.fontSizes.h1FontSize).toBe("1.5rem") expect(sidebarTheme.emotion.fontSizes.h2FontSize).toBe("1.25rem") }) it("handles complex sidebar theming scenario", () => { const mainTheme = createTheme(CUSTOM_THEME_NAME, { primaryColor: "red", backgroundColor: "white", secondaryBackgroundColor: "lightgray", textColor: "black", bodyFont: "serif", headingFontSizes: ["3rem", "2.5rem"], sidebar: { primaryColor: "blue", backgroundColor: "darkblue", textColor: "white", headingFontSizes: ["1.75rem", "1.5rem"], }, }) const sidebarTheme = createSidebarTheme(mainTheme) // Should use all sidebar overrides expect(sidebarTheme.emotion.colors.primary).toBe("blue") expect(sidebarTheme.emotion.colors.bgColor).toBe("darkblue") expect(sidebarTheme.emotion.colors.bodyText).toBe("white") expect(sidebarTheme.emotion.colors.secondaryBg).toBe("white") // Swapped from main bg expect(sidebarTheme.emotion.fontSizes.h1FontSize).toBe("1.75rem") expect(sidebarTheme.emotion.fontSizes.h2FontSize).toBe("1.5rem") // Should inherit main theme font expect(sidebarTheme.emotion.genericFonts.bodyFont).toBe( lightTheme.emotion.fonts.serif ) expect(sidebarTheme.emotion.inSidebar).toBe(true) }) it("handles sidebar with custom light/dark themes", () => { const themeInput = new CustomThemeConfig({ primaryColor: "red", backgroundColor: "white", light: { sidebar: { primaryColor: "lightblue", }, }, dark: { sidebar: { primaryColor: "darkblue", }, }, }) const [lightTheme, darkTheme] = createCustomThemes(themeInput) const lightSidebarTheme = createSidebarTheme(lightTheme) const darkSidebarTheme = createSidebarTheme(darkTheme) // Light sidebar should use light section config expect(lightSidebarTheme.emotion.colors.primary).toBe("lightblue") // Dark sidebar should use dark section config expect(darkSidebarTheme.emotion.colors.primary).toBe("darkblue") }) }) })