Files
Psychotoxical-psysonic/src/components/settings/ThemeStoreSection.test.tsx
T
Psychotoxical 0878cbf308 feat(themes): Theme Store install counts, downloads & sort (#1036)
* feat(themes): install counts, downloads, popularity & sort in the Theme Store

Each registry theme now carries an install count and a last-changed date. Store
rows show them in a dedicated meta panel (author, popularity bar, total
downloads, last changed), plus a sort dropdown (most popular / newest / name),
zebra-striped rows, a numbered pager, and a note that the stats refresh daily.
Adds a shared formatRelativeTime helper (formatLastSeen now delegates to it).

* test(themes): cover sort modes, pager jump and relative-time formatting

* fix(build): gate nvidia_quirk_active to Linux

Its only caller is the Linux branch of theme_animation_risk, so on Windows and
macOS the function was dead code and tripped clippy's -D warnings.

* docs: CHANGELOG + credits for theme store download stats & sort (#1036)
2026-06-08 22:05:41 +02:00

231 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import { ThemeStoreSection } from './ThemeStoreSection';
import type { FetchRegistryResult, Registry, RegistryTheme } from '@/utils/themes/themeRegistry';
// Control the registry the store browses so pagination/refresh are deterministic.
vi.mock('@/utils/themes/themeRegistry', () => ({
fetchRegistry: vi.fn(),
fetchThemeCss: vi.fn(async () => 'css'),
cdnUrl: (p: string) => `https://cdn.example/${p}`,
}));
import { fetchRegistry } from '@/utils/themes/themeRegistry';
const fetchRegistryMock = vi.mocked(fetchRegistry);
/** A registry with `n` themes named `Theme 01`…`Theme NN` (zero-padded so the
* component's alphabetical sort matches numeric order). */
function makeRegistry(n: number): Registry {
const themes = Array.from({ length: n }, (_, i) => {
const num = String(i + 1).padStart(2, '0');
return {
id: `t${num}`,
name: `Theme ${num}`,
author: 'Tester',
version: '1.0.0',
description: `Description ${num}`,
mode: (i % 2 === 0 ? 'dark' : 'light') as 'dark' | 'light',
css: `themes/t${num}/theme.css`,
thumbnail: `themes/t${num}/thumb.png`,
tags: [],
};
});
return { schemaVersion: 1, generatedAt: '2026-06-07T00:00:00Z', themes };
}
const rows = (container: HTMLElement) => container.querySelectorAll('.theme-store-row');
/** Visible theme names in row order (the first span in each row is the name). */
const rowNames = (container: HTMLElement) =>
[...container.querySelectorAll('.theme-store-row')].map(r => r.querySelector('span')?.textContent);
function mkTheme(id: string, name: string, extra: Partial<RegistryTheme> = {}): RegistryTheme {
return {
id,
name,
author: 'Tester',
version: '1.0.0',
description: `Description ${id}`,
mode: 'dark',
css: `themes/${id}/theme.css`,
thumbnail: `themes/${id}/thumb.webp`,
tags: [],
...extra,
};
}
function registryOf(themes: RegistryTheme[]): Registry {
return { schemaVersion: 1, generatedAt: '2026-06-08T00:00:00Z', themes };
}
/** Open the sort dropdown and pick an option by its visible label. */
async function selectSort(
user: ReturnType<typeof userEvent.setup>,
container: HTMLElement,
optionLabel: string,
) {
await user.click(container.querySelector('.custom-select-trigger') as HTMLElement);
// The option closes the list on mousedown, so fire it directly.
fireEvent.mouseDown(await screen.findByRole('option', { name: optionLabel }));
}
describe('ThemeStoreSection — pagination & refresh', () => {
beforeEach(() => {
vi.clearAllMocks();
// jsdom has no layout engine; goToPage() scrolls the list back up.
Element.prototype.scrollIntoView = vi.fn();
});
it('shows only one page of themes and a pager when the catalogue is large', async () => {
fetchRegistryMock.mockResolvedValue({ registry: makeRegistry(30), stale: false });
const { container } = renderWithProviders(<ThemeStoreSection />);
await screen.findByText('Theme 01');
// PAGE_SIZE is 12 → 30 themes span 3 pages.
expect(rows(container)).toHaveLength(12);
expect(screen.getByRole('button', { name: '1', current: 'page' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '3' })).toBeInTheDocument();
// Page-2 themes are not rendered yet.
expect(screen.queryByText('Theme 13')).not.toBeInTheDocument();
});
it('does not paginate when everything fits on one page', async () => {
fetchRegistryMock.mockResolvedValue({ registry: makeRegistry(8), stale: false });
const { container } = renderWithProviders(<ThemeStoreSection />);
await screen.findByText('Theme 01');
expect(rows(container)).toHaveLength(8);
expect(screen.queryByLabelText('Next page')).not.toBeInTheDocument();
expect(screen.queryByText(/page 1 of/i)).not.toBeInTheDocument();
});
it('navigates between pages and disables prev/next at the bounds', async () => {
fetchRegistryMock.mockResolvedValue({ registry: makeRegistry(30), stale: false });
const { container } = renderWithProviders(<ThemeStoreSection />);
const user = userEvent.setup();
await screen.findByText('Theme 01');
expect(screen.getByLabelText('Previous page')).toBeDisabled();
await user.click(screen.getByLabelText('Next page'));
expect(screen.getByRole('button', { name: '2', current: 'page' })).toBeInTheDocument();
expect(screen.getByText('Theme 13')).toBeInTheDocument();
expect(screen.queryByText('Theme 01')).not.toBeInTheDocument();
expect(screen.getByLabelText('Previous page')).toBeEnabled();
await user.click(screen.getByLabelText('Next page'));
expect(screen.getByRole('button', { name: '3', current: 'page' })).toBeInTheDocument();
expect(rows(container)).toHaveLength(6); // 30 - 24
expect(screen.getByLabelText('Next page')).toBeDisabled();
});
it('filters the catalogue by the search query', async () => {
fetchRegistryMock.mockResolvedValue({ registry: makeRegistry(30), stale: false });
const { container } = renderWithProviders(<ThemeStoreSection />);
const user = userEvent.setup();
await screen.findByText('Theme 01');
await user.type(screen.getByRole('searchbox'), 'Theme 05');
await waitFor(() => expect(rows(container)).toHaveLength(1));
expect(screen.getByText('Theme 05')).toBeInTheDocument();
expect(screen.queryByText('Theme 04')).not.toBeInTheDocument();
});
it('resets to the first page when the search query changes', async () => {
fetchRegistryMock.mockResolvedValue({ registry: makeRegistry(30), stale: false });
renderWithProviders(<ThemeStoreSection />);
const user = userEvent.setup();
await screen.findByText('Theme 01');
await user.click(screen.getByLabelText('Next page'));
expect(screen.getByRole('button', { name: '2', current: 'page' })).toBeInTheDocument();
// 'theme' matches all 30 names, so the catalogue is unchanged in size — but
// the page must snap back to 1 so the user isn't stranded past the end.
await user.type(screen.getByRole('searchbox'), 'theme');
await waitFor(() => expect(screen.getByRole('button', { name: '1', current: 'page' })).toBeInTheDocument());
expect(screen.getByText('Theme 01')).toBeInTheDocument();
});
it('keeps the list mounted while refreshing (no scroll-resetting unmount)', async () => {
const reg = makeRegistry(30);
let resolveRefresh!: (v: FetchRegistryResult) => void;
const pending = new Promise<FetchRegistryResult>(res => { resolveRefresh = res; });
fetchRegistryMock
.mockResolvedValueOnce({ registry: reg, stale: false }) // initial load
.mockReturnValueOnce(pending); // manual refresh, held pending
const { container } = renderWithProviders(<ThemeStoreSection />);
const user = userEvent.setup();
await screen.findByText('Theme 01');
expect(container.querySelector('.animate-spin')).toBeNull();
await user.click(screen.getByLabelText('Refresh'));
// The whole point of the fix: refreshing must NOT swap the list for the
// full-page loading placeholder (which collapses the scroll viewport and
// jumps it to the top). The rows stay; only the icon spins.
expect(rows(container)).toHaveLength(12);
expect(screen.queryByText(/loading themes/i)).not.toBeInTheDocument();
expect(container.querySelector('.animate-spin')).not.toBeNull();
resolveRefresh({ registry: reg, stale: false });
await waitFor(() => expect(container.querySelector('.animate-spin')).toBeNull());
expect(rows(container)).toHaveLength(12);
});
it('shows an offline banner and hides the toolbar when the registry is unavailable', async () => {
fetchRegistryMock.mockRejectedValue(new Error('offline'));
renderWithProviders(<ThemeStoreSection />);
expect(await screen.findByText('The Theme Store is offline')).toBeInTheDocument();
// No catalogue to browse → the search/filter toolbar is not rendered.
expect(screen.queryByRole('searchbox')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
});
it('sorts by most popular, newest and name, and shows the download count', async () => {
const themes = [
mkTheme('a', 'Alpha', { installs: 10, updatedAt: '2026-06-01T00:00:00Z' }),
mkTheme('b', 'Bravo', { installs: 500, updatedAt: '2026-06-05T00:00:00Z' }),
mkTheme('c', 'Charlie', { installs: 0, updatedAt: '2026-06-03T00:00:00Z' }),
];
fetchRegistryMock.mockResolvedValue({ registry: registryOf(themes), stale: false });
const { container } = renderWithProviders(<ThemeStoreSection />);
const user = userEvent.setup();
await screen.findByText('Bravo');
// Default sort is most-downloaded first: Bravo (500) > Alpha (10) > Charlie (0).
expect(rowNames(container)).toEqual(['Bravo', 'Alpha', 'Charlie']);
// The download count renders in the meta panel.
expect(screen.getByText('500')).toBeInTheDocument();
// Newest first: Bravo (06-05) > Charlie (06-03) > Alpha (06-01).
await selectSort(user, container, 'Newest');
await waitFor(() => expect(rowNames(container)).toEqual(['Bravo', 'Charlie', 'Alpha']));
// Alphabetical.
await selectSort(user, container, 'Alphabetical');
await waitFor(() => expect(rowNames(container)).toEqual(['Alpha', 'Bravo', 'Charlie']));
});
it('jumps directly to a page via the numbered pager', async () => {
fetchRegistryMock.mockResolvedValue({ registry: makeRegistry(30), stale: false });
renderWithProviders(<ThemeStoreSection />);
const user = userEvent.setup();
await screen.findByText('Theme 01');
await user.click(screen.getByRole('button', { name: '3' }));
// Page 3 holds items 2530; page 1 is gone and page 3 is marked current.
expect(screen.getByText('Theme 25')).toBeInTheDocument();
expect(screen.queryByText('Theme 01')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '3', current: 'page' })).toBeInTheDocument();
});
});