Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

93 changes: 86 additions & 7 deletions web/__test__/store/theme.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@
* Theme store test coverage
*/

import { nextTick, ref } from 'vue';
import { createPinia, setActivePinia } from 'pinia';
import { createApp, nextTick, ref } from 'vue';
import { setActivePinia } from 'pinia';

import { defaultColors } from '~/themes/default';
import hexToRgba from 'hex-to-rgba';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { useThemeStore } from '~/store/theme';
import type { Theme } from '~/themes/types';

import { globalPinia } from '~/store/globalPinia';
import { THEME_STORAGE_KEY, useThemeStore } from '~/store/theme';

vi.mock('@vue/apollo-composable', () => ({
useQuery: () => ({
Expand All @@ -25,15 +28,23 @@ vi.mock('hex-to-rgba', () => ({
}));

describe('Theme Store', () => {
let store: ReturnType<typeof useThemeStore>;
const originalAddClassFn = document.body.classList.add;
const originalRemoveClassFn = document.body.classList.remove;
const originalStyleCssText = document.body.style.cssText;
const originalDocumentElementSetProperty = document.documentElement.style.setProperty;
const originalDocumentElementAddClass = document.documentElement.classList.add;
const originalDocumentElementRemoveClass = document.documentElement.classList.remove;

let store: ReturnType<typeof useThemeStore> | undefined;
let app: ReturnType<typeof createApp> | undefined;

beforeEach(() => {
setActivePinia(createPinia());
store = useThemeStore();
app = createApp({ render: () => null });
app.use(globalPinia);
setActivePinia(globalPinia);
store = undefined;
window.localStorage.clear();
delete (globalPinia.state.value as Record<string, unknown>).theme;

document.body.classList.add = vi.fn();
document.body.classList.remove = vi.fn();
Expand All @@ -51,16 +62,34 @@ describe('Theme Store', () => {
});

afterEach(() => {
// Restore original methods
store?.$dispose();
store = undefined;
app?.unmount();
app = undefined;

document.body.classList.add = originalAddClassFn;
document.body.classList.remove = originalRemoveClassFn;
document.body.style.cssText = originalStyleCssText;
document.documentElement.style.setProperty = originalDocumentElementSetProperty;
document.documentElement.classList.add = originalDocumentElementAddClass;
document.documentElement.classList.remove = originalDocumentElementRemoveClass;
vi.restoreAllMocks();
});

const createStore = () => {
if (!store) {
store = useThemeStore();
}

return store;
};

describe('State and Initialization', () => {
it('should initialize with default theme', () => {
const store = createStore();

expect(typeof store.$persist).toBe('function');

expect(store.theme).toEqual({
name: 'white',
banner: false,
Expand All @@ -74,6 +103,8 @@ describe('Theme Store', () => {
});

it('should compute darkMode correctly', () => {
const store = createStore();

expect(store.darkMode).toBe(false);

store.setTheme({ ...store.theme, name: 'black' });
Expand All @@ -87,6 +118,8 @@ describe('Theme Store', () => {
});

it('should compute bannerGradient correctly', () => {
const store = createStore();

expect(store.bannerGradient).toBeUndefined();

store.setTheme({
Expand All @@ -112,6 +145,8 @@ describe('Theme Store', () => {

describe('Actions', () => {
it('should set theme correctly', () => {
const store = createStore();

const newTheme = {
name: 'black',
banner: true,
Expand All @@ -127,6 +162,8 @@ describe('Theme Store', () => {
});

it('should update body classes for dark mode', async () => {
const store = createStore();

store.setTheme({ ...store.theme, name: 'black' });

await nextTick();
Expand All @@ -141,6 +178,8 @@ describe('Theme Store', () => {
});

it('should update activeColorVariables when theme changes', async () => {
const store = createStore();

store.setTheme({
...store.theme,
name: 'white',
Expand Down Expand Up @@ -170,6 +209,7 @@ describe('Theme Store', () => {
});

it('should handle banner gradient correctly', async () => {
const store = createStore();
const mockHexToRgba = vi.mocked(hexToRgba);

mockHexToRgba.mockClear();
Expand Down Expand Up @@ -200,5 +240,44 @@ describe('Theme Store', () => {
'linear-gradient(90deg, rgba(mock-#112233-0) 0, rgba(mock-#112233-0.7) 90%)'
);
});

it('should hydrate theme from cache when available', () => {
const cachedTheme = {
name: 'black',
banner: true,
bannerGradient: false,
bgColor: '#222222',
descriptionShow: true,
metaColor: '#aaaaaa',
textColor: '#ffffff',
} satisfies Theme;

window.localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify({ theme: cachedTheme }));

const store = createStore();

expect(store.theme).toEqual(cachedTheme);
});

it('should persist server theme responses to cache', async () => {
const store = createStore();

const serverTheme = {
name: 'gray',
banner: false,
bannerGradient: false,
bgColor: '#111111',
descriptionShow: false,
metaColor: '#999999',
textColor: '#eeeeee',
} satisfies Theme;

store.setTheme(serverTheme, { source: 'server' });
await nextTick();

expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toEqual(
JSON.stringify({ theme: serverTheme })
);
});
});
});
1 change: 1 addition & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
"marked": "16.2.1",
"marked-base-url": "1.1.7",
"pinia": "3.0.3",
"pinia-plugin-persistedstate": "4.7.1",
"postcss-import": "16.1.1",
"semver": "7.7.2",
"tailwind-merge": "2.6.0",
Expand Down
4 changes: 2 additions & 2 deletions web/src/components/DevThemeSwitcher.standalone.vue
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ const updateTheme = (themeName: string, skipUrlUpdate = false) => {
// ignore
}

themeStore.setTheme({ name: themeName }, true);
themeStore.setTheme({ name: themeName });
themeStore.setCssVars();

const linkId = 'dev-theme-css-link';
Expand Down Expand Up @@ -100,7 +100,7 @@ onMounted(() => {
if (!existingLink || !existingLink.href) {
updateTheme(initialTheme, true);
} else {
themeStore.setTheme({ name: initialTheme }, true);
themeStore.setTheme({ name: initialTheme });
themeStore.setCssVars();
}
});
Expand Down
3 changes: 3 additions & 0 deletions web/src/store/globalPinia.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { createPinia, setActivePinia } from 'pinia';

import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';

// Create a single shared Pinia instance for all web components
export const globalPinia = createPinia();
globalPinia.use(piniaPluginPersistedstate);

// IMPORTANT: Set it as the active pinia instance immediately
// This ensures stores work even when called during component setup
Expand Down
Loading
Loading