diff --git a/web/__test__/components/HeaderThemes.test.ts b/web/__test__/components/HeaderThemes.test.ts new file mode 100644 index 0000000000..88872ff65a --- /dev/null +++ b/web/__test__/components/HeaderThemes.test.ts @@ -0,0 +1,243 @@ +import { ref } from 'vue'; +import { setActivePinia } from 'pinia'; +import { provideApolloClient } from '@vue/apollo-composable'; +import { mount } from '@vue/test-utils'; + +import { ApolloClient, InMemoryCache } from '@apollo/client/core'; +import { createTestingPinia } from '@pinia/testing'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { VueWrapper } from '@vue/test-utils'; +import type { HeaderLogoStyle } from '~/themes/types'; +import type { Server, ServerconnectPluginInstalled, ServerState } from '~/types/server'; +import type { Pinia } from 'pinia'; + +import Header from '~/components/Header.standalone.vue'; +import { useThemeStore } from '~/store/theme'; + +vi.mock('@vueuse/core', () => ({ + useClipboard: () => ({ copy: vi.fn(), copied: ref(false), isSupported: ref(true) }), + useLocalStorage: (_key: string, initialValue: T) => ref(initialValue), +})); + +vi.mock('@unraid/ui', () => ({ + DropdownMenu: { + template: '
', + }, + Button: { template: '', props: ['variant', 'size'] }, + cn: (...classes: string[]) => classes.filter(Boolean).join(' '), + isDarkModeActive: vi.fn(() => false), +})); + +vi.mock('~/store/callbackActions', () => ({ + useCallbackActionsStore: vi.fn(() => ({ watcher: vi.fn(), callbackData: ref(null) })), +})); + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: (key: string) => key }), +})); + +const server: Server = { + name: 'DEVGEN', + description: 'Dev Server', + guid: 'TEST-GUID', + keyfile: 'keyfile.key', + lanIp: '192.168.1.100', + connectPluginInstalled: 'dynamix.unraid.net.plg' as ServerconnectPluginInstalled, + state: 'PRO' as ServerState, + dateTimeFormat: { date: 'YYYY-MM-DD', time: 'HH:mm' }, + deviceCount: 5, + flashProduct: 'TestFlash', + flashVendor: 'TestVendor', + regGuid: 'REG-GUID', + regTm: 1678886400, + regTo: 'Test User', + regTy: 'Pro', + regExp: undefined, + regUpdatesExpired: false, + registered: true, + wanIp: '8.8.8.8', +}; + +const stubs = { + HeaderVersion: { template: '
' }, + ArrayUsage: { template: '
' }, + UpcServerStatus: { template: '
', props: ['class'] }, + NotificationsSidebar: { template: '
' }, + UpcDropdownContent: { template: '
' }, + UpcDropdownTrigger: { template: '' }, +}; + +/** + * The four shipped webGUI themes. `white`/`black` are top-nav; `gray`/`azure` are + * sidebar themes, which is where the boot placeholder previously mismatched the + * mounted logo (the mounted logo compressed ~9% at mobile widths). + */ +const THEMES = [ + { name: 'white', sidebar: false }, + { name: 'black', sidebar: false }, + { name: 'gray', sidebar: true }, + { name: 'azure', sidebar: true }, +] as const; + +/** + * A focused, stable signature of the layout decisions that must not regress — + * far less brittle than snapshotting raw markup, and a diff points straight at + * the offending class. + */ +const layoutSignature = (wrapper: VueWrapper) => { + const root = wrapper.get('#UnraidHeader'); + const gradient = root.find('.unraid-banner-gradient-layer'); + const logoSvg = root.find('.uh-logo-block a svg'); + return { + gradientLayer: gradient.exists() ? [...gradient.classes()].sort().join(' ') : null, + logoSvg: logoSvg.exists() ? [...logoSvg.classes()].sort().join(' ') : null, + regions: { + logoBlock: root.find('.uh-logo-block').exists(), + metaRight: root.find('.uh-meta-right').exists(), + navRight: root.find('.uh-nav-right').exists(), + version: root.find('.uh-version').exists(), + // Sidebar themes are the only ones webGUI passes show-array-usage for, so + // this differentiates the sidebar snapshots from the top-nav ones. + arrayUsage: root.find('[data-testid="array-usage"]').exists(), + }, + // Driven by the theme's `banner` flag, so this also proves the theme was + // actually applied to the store rather than silently defaulting. + metaOverBanner: root.find('.uh-meta-over-banner').exists(), + }; +}; + +describe('Header.standalone.vue theme layout', () => { + let pinia: Pinia; + const wrappers: VueWrapper[] = []; + + const mountWithTheme = ( + themeName: string, + { + logoStyle = '', + showArrayUsage = false, + }: { logoStyle?: HeaderLogoStyle | ''; showArrayUsage?: boolean } = {} + ) => { + const themeStore = useThemeStore(); + themeStore.setTheme({ + name: themeName, + banner: true, + bannerGradient: true, + descriptionShow: true, + textColor: '', + metaColor: '', + bgColor: '', + }); + const wrapper = mount(Header, { + props: { + server: JSON.stringify(server), + headerLogoStyle: logoStyle, + // webGUI only passes this for sidebar themes with usage display enabled. + ...(showArrayUsage ? { showArrayUsage: 'true' } : {}), + }, + global: { plugins: [pinia], stubs }, + }); + wrappers.push(wrapper); + return wrapper; + }; + + beforeEach(() => { + provideApolloClient(new ApolloClient({ cache: new InMemoryCache() })); + // stubActions: false so themeStore.setTheme() actually mutates the store. + // With the default (stubbed) actions every case would silently run against + // the default theme, making the per-theme coverage meaningless. + pinia = createTestingPinia({ + createSpy: vi.fn, + initialState: { server: { ...server } }, + stubActions: false, + }); + setActivePinia(pinia); + }); + + afterEach(() => { + wrappers.splice(0).forEach((wrapper) => wrapper.unmount()); + vi.restoreAllMocks(); + }); + + // Each theme is snapshotted in the configuration webGUI actually ships it in: + // sidebar themes (gray/azure) carry the array-usage bar, top-nav themes do not. + it.each(THEMES.map((theme) => [theme.name, theme] as const))( + 'renders a consistent layout on the %s theme', + (_name, theme) => { + const wrapper = mountWithTheme(theme.name, { showArrayUsage: theme.sidebar }); + expect(layoutSignature(wrapper)).toMatchSnapshot(); + } + ); + + it.each(THEMES.filter((theme) => theme.sidebar).map((theme) => [theme.name] as const))( + 'renders the array-usage bar on the %s sidebar theme', + (name) => { + expect( + mountWithTheme(name, { showArrayUsage: true }).find('[data-testid="array-usage"]').exists() + ).toBe(true); + } + ); + + it.each(THEMES.filter((theme) => !theme.sidebar).map((theme) => [theme.name] as const))( + 'omits the array-usage bar on the %s top-nav theme', + (name) => { + expect(mountWithTheme(name).find('[data-testid="array-usage"]').exists()).toBe(false); + } + ); + + it.each(['gradient', 'theme'] as const)('renders the %s header logo style', (logoStyle) => { + expect(layoutSignature(mountWithTheme('white', { logoStyle })).logoSvg).toMatchSnapshot(); + }); + + /** + * Regression guard: the logo must keep a fixed width so the server-rendered + * boot placeholder in webGUI `Header.php` (a static 14rem/16rem SVG) matches it + * exactly through mount. `max-w-full` previously let the mounted logo compress + * to ~127px on sidebar themes at mobile widths, so the placeholder visibly + * resized on upgrade. + */ + it.each(THEMES.map((theme) => [theme.name, theme] as const))( + 'keeps the logo a fixed size on the %s theme so the boot placeholder cannot resize on mount', + (_name, theme) => { + const logoSvg = mountWithTheme(theme.name).get('.uh-logo-block a svg'); + expect(logoSvg.classes()).toContain('shrink-0'); + expect(logoSvg.classes()).not.toContain('max-w-full'); + expect(logoSvg.classes()).toContain('w-[14rem]'); + expect(logoSvg.classes()).toContain('xs:w-[16rem]'); + } + ); + + /** + * Regression guard: the banner gradient must stay within the legacy header's + * `min(30%, 320px)` edge width. A bare `left-[55%]` covered the right 45% + * (~576px at 1280px, ~1.8x legacy) and visibly obscured users' banner images. + */ + it('scopes the banner gradient to the legacy edge width', () => { + const gradient = mountWithTheme('white').get('.unraid-banner-gradient-layer'); + expect(gradient.classes()).toContain('w-[min(30%,320px)]'); + expect(gradient.classes()).toContain('right-0'); + expect(gradient.classes()).not.toContain('left-[55%]'); + }); + + it('omits the banner gradient styling hook only when the theme disables it', () => { + const themeStore = useThemeStore(); + themeStore.setTheme({ + name: 'white', + banner: false, + bannerGradient: false, + descriptionShow: true, + textColor: '', + metaColor: '', + bgColor: '', + }); + const wrapper = mount(Header, { + props: { server: JSON.stringify(server) }, + global: { plugins: [pinia], stubs }, + }); + wrappers.push(wrapper); + // The layer is always present; `--banner-gradient` is null when disabled, so + // it self-gates in CSS rather than by conditional rendering. + expect(wrapper.find('.unraid-banner-gradient-layer').exists()).toBe(true); + expect(wrapper.find('.uh-meta-over-banner').exists()).toBe(false); + }); +}); diff --git a/web/__test__/components/__snapshots__/HeaderThemes.test.ts.snap b/web/__test__/components/__snapshots__/HeaderThemes.test.ts.snap new file mode 100644 index 0000000000..acc7c2ec62 --- /dev/null +++ b/web/__test__/components/__snapshots__/HeaderThemes.test.ts.snap @@ -0,0 +1,65 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Header.standalone.vue theme layout > renders a consistent layout on the azure theme 1`] = ` +{ + "gradientLayer": "absolute inset-y-0 pointer-events-none right-0 unraid-banner-gradient-layer w-[min(30%,320px)] z-0", + "logoSvg": "h-auto max-h-[3rem] shrink-0 w-[14rem] xs:w-[16rem]", + "metaOverBanner": true, + "regions": { + "arrayUsage": true, + "logoBlock": true, + "metaRight": true, + "navRight": true, + "version": true, + }, +} +`; + +exports[`Header.standalone.vue theme layout > renders a consistent layout on the black theme 1`] = ` +{ + "gradientLayer": "absolute inset-y-0 pointer-events-none right-0 unraid-banner-gradient-layer w-[min(30%,320px)] z-0", + "logoSvg": "h-auto max-h-[3rem] shrink-0 w-[14rem] xs:w-[16rem]", + "metaOverBanner": true, + "regions": { + "arrayUsage": false, + "logoBlock": true, + "metaRight": true, + "navRight": true, + "version": true, + }, +} +`; + +exports[`Header.standalone.vue theme layout > renders a consistent layout on the gray theme 1`] = ` +{ + "gradientLayer": "absolute inset-y-0 pointer-events-none right-0 unraid-banner-gradient-layer w-[min(30%,320px)] z-0", + "logoSvg": "h-auto max-h-[3rem] shrink-0 w-[14rem] xs:w-[16rem]", + "metaOverBanner": true, + "regions": { + "arrayUsage": true, + "logoBlock": true, + "metaRight": true, + "navRight": true, + "version": true, + }, +} +`; + +exports[`Header.standalone.vue theme layout > renders a consistent layout on the white theme 1`] = ` +{ + "gradientLayer": "absolute inset-y-0 pointer-events-none right-0 unraid-banner-gradient-layer w-[min(30%,320px)] z-0", + "logoSvg": "h-auto max-h-[3rem] shrink-0 w-[14rem] xs:w-[16rem]", + "metaOverBanner": true, + "regions": { + "arrayUsage": false, + "logoBlock": true, + "metaRight": true, + "navRight": true, + "version": true, + }, +} +`; + +exports[`Header.standalone.vue theme layout > renders the gradient header logo style 1`] = `"h-auto max-h-[3rem] shrink-0 w-[14rem] xs:w-[16rem]"`; + +exports[`Header.standalone.vue theme layout > renders the theme header logo style 1`] = `"h-auto max-h-[3rem] shrink-0 text-header-text-primary w-[14rem] xs:w-[16rem]"`; diff --git a/web/src/components/Header.standalone.vue b/web/src/components/Header.standalone.vue index 8a306de8d6..7630aad806 100644 --- a/web/src/components/Header.standalone.vue +++ b/web/src/components/Header.standalone.vue @@ -100,11 +100,13 @@ const copyLanIp = async () => { `.unraid-banner-gradient-layer` class (styled in main.css) paints `var(--banner-gradient)`, which is null when the banner gradient is disabled, so this self-gates. Sits behind the content columns (z-0). - `left-[55%]` starts the darkening at the right ~45%, matching the legacy - `#header.image::before` edge gradient in main.css (kept in sync by eye). + Width matches the legacy `#header.image::before` edge gradient in main.css + (`background-size: min(30%, 320px) 100%`) rather than a bare percentage: + `left-[55%]` covered the right 45% (576px at a 1280px viewport), roughly + 1.8x the legacy 320px, which visibly obscured users' banner images. -->