mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
test: expand honest whole-repo coverage
Adds broad coverage across stores, hooks, and runtime utilities while switching `vitest.config.ts` to an explicit whole-repo include list. This turns the coverage report into a real repo-wide baseline instead of an imported-file subset.
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import * as React from 'react';
|
||||
import { createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useCurrentTime } from '../use-current-time';
|
||||
import useIsMobile from '../use-is-mobile';
|
||||
import useWindowWidth from '../use-window-width';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||
|
||||
let latestValue: unknown;
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
const HookHarness = ({ useValue }: { useValue: () => unknown }) => {
|
||||
const value = useValue();
|
||||
latestValue = value;
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderHookValue = (useValue: () => unknown) => {
|
||||
act(() => {
|
||||
root.render(createElement(HookHarness, { useValue }));
|
||||
});
|
||||
|
||||
return latestValue;
|
||||
};
|
||||
|
||||
describe('browser hooks', () => {
|
||||
beforeEach(() => {
|
||||
latestValue = undefined;
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
value: 1024,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('tracks the window width across resize events', () => {
|
||||
expect(renderHookValue(() => useWindowWidth())).toBe(1024);
|
||||
|
||||
act(() => {
|
||||
window.innerWidth = 480;
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
});
|
||||
|
||||
expect(latestValue).toBe(480);
|
||||
});
|
||||
|
||||
it('derives the mobile breakpoint from the current window width', () => {
|
||||
renderHookValue(() => useIsMobile());
|
||||
expect(latestValue).toBe(false);
|
||||
|
||||
act(() => {
|
||||
window.innerWidth = 639;
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
});
|
||||
|
||||
expect(latestValue).toBe(true);
|
||||
});
|
||||
|
||||
it('updates the current time on the configured interval', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
|
||||
|
||||
expect(renderHookValue(() => useCurrentTime(30))).toBe(1_704_067_200);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(30_000);
|
||||
});
|
||||
|
||||
expect(latestValue).toBe(1_704_067_230);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import * as React from 'react';
|
||||
import { createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useAccountSubplebbitAddresses } from '../use-account-subplebbit-addresses';
|
||||
import { useAccountSubplebbitsWithMetadata } from '../use-account-subplebbits-with-metadata';
|
||||
import useAuthorPrivileges from '../use-author-privileges';
|
||||
import { useBoardFeedPageSize } from '../use-board-feed-page-size';
|
||||
import { useBoardPseudonymityMode } from '../use-board-pseudonymity-mode';
|
||||
import useCountLinksInReplies from '../use-count-links-in-replies';
|
||||
import { useFilteredDirectoryAddresses } from '../use-filtered-directory-addresses';
|
||||
import useAllFeedFilterStore from '../../stores/use-all-feed-filter-store';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
account: undefined as unknown,
|
||||
accountSubplebbits: {} as Record<string, unknown>,
|
||||
directories: [] as Array<{ address: string; nsfw?: boolean }>,
|
||||
directoryLookup: {} as Record<string, unknown>,
|
||||
flattenedReplies: [] as unknown[],
|
||||
subplebbitSnapshot: undefined as unknown,
|
||||
}));
|
||||
|
||||
vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({
|
||||
useAccount: () => testState.account,
|
||||
useAccountSubplebbits: () => ({ accountSubplebbits: testState.accountSubplebbits }),
|
||||
}));
|
||||
|
||||
vi.mock('@bitsocialhq/bitsocial-react-hooks/dist/lib/utils', () => ({
|
||||
flattenCommentsPages: () => testState.flattenedReplies,
|
||||
}));
|
||||
|
||||
vi.mock('../use-directories', () => ({
|
||||
useDirectories: () => testState.directories,
|
||||
useDirectoryByAddress: (address: string | undefined) => (address ? testState.directoryLookup[address] : undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../use-stable-subplebbit', () => ({
|
||||
useSubplebbitField: (_address: string | undefined, selector: (subplebbit: unknown) => unknown) => selector(testState.subplebbitSnapshot),
|
||||
}));
|
||||
|
||||
let latestValue: unknown;
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
let renderCount = 0;
|
||||
|
||||
const HookHarness = ({ useValue }: { useValue: () => unknown }) => {
|
||||
const value = useValue();
|
||||
latestValue = value;
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderHookValue = (useValue: () => unknown) => {
|
||||
act(() => {
|
||||
root.render(createElement(HookHarness, { key: renderCount++, useValue }));
|
||||
});
|
||||
|
||||
return latestValue;
|
||||
};
|
||||
|
||||
describe('selector hooks', () => {
|
||||
beforeEach(() => {
|
||||
latestValue = undefined;
|
||||
renderCount = 0;
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
testState.account = undefined;
|
||||
testState.accountSubplebbits = {};
|
||||
testState.directories = [];
|
||||
testState.directoryLookup = {};
|
||||
testState.flattenedReplies = [];
|
||||
testState.subplebbitSnapshot = undefined;
|
||||
useAllFeedFilterStore.getState().setFilter('all');
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('derives account board addresses and metadata from cached account subplebbits', () => {
|
||||
testState.accountSubplebbits = {
|
||||
'music.eth': { address: 'music.eth', title: '/mu/ - Music' },
|
||||
'tech.eth': { address: 'tech.eth', title: '/g/ - Technology' },
|
||||
};
|
||||
|
||||
expect(renderHookValue(() => useAccountSubplebbitAddresses())).toEqual(['music.eth', 'tech.eth']);
|
||||
expect(renderHookValue(() => useAccountSubplebbitsWithMetadata())).toEqual([
|
||||
{ address: 'music.eth', title: '/mu/ - Music' },
|
||||
{ address: 'tech.eth', title: '/g/ - Technology' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('computes moderator privileges and whether the current account authored the comment', () => {
|
||||
testState.account = { author: { address: '0xme' } };
|
||||
testState.subplebbitSnapshot = {
|
||||
roles: {
|
||||
'0xauthor': { role: 'moderator' },
|
||||
'0xme': { role: 'admin' },
|
||||
},
|
||||
};
|
||||
|
||||
expect(renderHookValue(() => useAuthorPrivileges({ commentAuthorAddress: '0xauthor', subplebbitAddress: 'music.eth' }))).toEqual({
|
||||
isCommentAuthorMod: true,
|
||||
isAccountMod: true,
|
||||
isAccountCommentAuthor: false,
|
||||
commentAuthorRole: 'moderator',
|
||||
accountAuthorRole: 'admin',
|
||||
});
|
||||
|
||||
expect(renderHookValue(() => useAuthorPrivileges({ commentAuthorAddress: '0xme', subplebbitAddress: 'music.eth' }))).toEqual({
|
||||
isCommentAuthorMod: true,
|
||||
isAccountMod: true,
|
||||
isAccountCommentAuthor: true,
|
||||
commentAuthorRole: 'admin',
|
||||
accountAuthorRole: 'admin',
|
||||
});
|
||||
});
|
||||
|
||||
it('derives board page sizes from directory metadata and falls back when unavailable', () => {
|
||||
expect(renderHookValue(() => useBoardFeedPageSize({ features: { postsPerPage: 22 } } as never))).toEqual({
|
||||
guiPostsPerPage: 22,
|
||||
maxGuiPages: 10,
|
||||
paginationFeedPostsPerPage: 220,
|
||||
infiniteFeedPostsPerPage: 22,
|
||||
});
|
||||
|
||||
expect(renderHookValue(() => useBoardFeedPageSize(undefined))).toEqual({
|
||||
guiPostsPerPage: 15,
|
||||
maxGuiPages: 10,
|
||||
paginationFeedPostsPerPage: 150,
|
||||
infiniteFeedPostsPerPage: 15,
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers live pseudonymity metadata and falls back to directory entries', () => {
|
||||
testState.directoryLookup = {
|
||||
'music.eth': {
|
||||
address: 'music.eth',
|
||||
features: { pseudonymityMode: 'directory-mode' },
|
||||
},
|
||||
};
|
||||
testState.subplebbitSnapshot = {
|
||||
features: { pseudonymityMode: 'live-mode' },
|
||||
};
|
||||
|
||||
expect(renderHookValue(() => useBoardPseudonymityMode('music.eth'))).toBe('live-mode');
|
||||
|
||||
testState.subplebbitSnapshot = {
|
||||
features: {},
|
||||
};
|
||||
|
||||
expect(renderHookValue(() => useBoardPseudonymityMode('music.eth'))).toBe('directory-mode');
|
||||
});
|
||||
|
||||
it('counts link-bearing replies and supports a reply preview limit', () => {
|
||||
testState.flattenedReplies = [{ link: 'https://a.test' }, { link: undefined }, { link: 'https://b.test' }];
|
||||
|
||||
expect(renderHookValue(() => useCountLinksInReplies({ replies: {} } as never))).toBe(2);
|
||||
expect(renderHookValue(() => useCountLinksInReplies({ replies: {} } as never, 2))).toBe(1);
|
||||
});
|
||||
|
||||
it('filters directory addresses according to the all-feed mode', () => {
|
||||
testState.directories = [{ address: 'music.eth', nsfw: false }, { address: 'flash.eth', nsfw: true }, { address: 'tech.eth' }];
|
||||
|
||||
expect(renderHookValue(() => useFilteredDirectoryAddresses())).toEqual(['music.eth', 'flash.eth', 'tech.eth']);
|
||||
|
||||
act(() => {
|
||||
useAllFeedFilterStore.getState().setFilter('nsfw');
|
||||
});
|
||||
expect(renderHookValue(() => useFilteredDirectoryAddresses())).toEqual(['flash.eth']);
|
||||
|
||||
act(() => {
|
||||
useAllFeedFilterStore.getState().setFilter('sfw');
|
||||
});
|
||||
expect(renderHookValue(() => useFilteredDirectoryAddresses())).toEqual(['music.eth', 'tech.eth']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import useAuthorAddressClick from '../use-author-address-click';
|
||||
|
||||
describe('useAuthorAddressClick', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('highlights matching author nodes except the op post and clears previous highlights', () => {
|
||||
document.body.innerHTML = `
|
||||
<span class="highlight" data-author-address="old" data-post-cid="post-1" data-cid="reply-old"></span>
|
||||
<span data-author-address="0x1...3" data-post-cid="post-1" data-cid="post-1"></span>
|
||||
<span data-author-address="0x1...3" data-post-cid="post-1" data-cid="reply-1"></span>
|
||||
<span data-author-address="0x1...3" data-post-cid="post-1" data-cid="reply-2"></span>
|
||||
`;
|
||||
|
||||
const handleUserAddressClick = useAuthorAddressClick();
|
||||
handleUserAddressClick('0x1...3', 'post-1');
|
||||
|
||||
const matches = document.querySelectorAll('[data-author-address="0x1...3"][data-post-cid="post-1"]');
|
||||
expect(matches[0].classList.contains('highlight')).toBe(false);
|
||||
expect(matches[1].classList.contains('highlight')).toBe(true);
|
||||
expect(matches[2].classList.contains('highlight')).toBe(true);
|
||||
expect(document.querySelector('[data-author-address="old"]')?.classList.contains('highlight')).toBe(false);
|
||||
});
|
||||
|
||||
it('toggles the highlight off when the same address is clicked again', () => {
|
||||
document.body.innerHTML = `
|
||||
<span data-author-address="0x1...3" data-post-cid="post-1" data-cid="reply-1"></span>
|
||||
<span data-author-address="0x1...3" data-post-cid="post-1" data-cid="reply-2"></span>
|
||||
`;
|
||||
|
||||
const handleUserAddressClick = useAuthorAddressClick();
|
||||
handleUserAddressClick('0x1...3', 'post-1');
|
||||
handleUserAddressClick('0x1...3', 'post-1');
|
||||
|
||||
const matches = document.querySelectorAll('[data-author-address="0x1...3"][data-post-cid="post-1"]');
|
||||
expect(matches[0].classList.contains('highlight')).toBe(false);
|
||||
expect(matches[1].classList.contains('highlight')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const testState = vi.hoisted(() => {
|
||||
const i18next = {
|
||||
init: vi.fn(),
|
||||
use: vi.fn(),
|
||||
};
|
||||
i18next.use.mockImplementation(() => i18next);
|
||||
|
||||
return {
|
||||
backendPlugin: { name: 'backend-plugin' },
|
||||
detectorPlugin: { name: 'detector-plugin' },
|
||||
i18next,
|
||||
reactPlugin: { name: 'react-plugin' },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('i18next', () => ({
|
||||
default: testState.i18next,
|
||||
}));
|
||||
|
||||
vi.mock('i18next-http-backend', () => ({
|
||||
default: testState.backendPlugin,
|
||||
}));
|
||||
|
||||
vi.mock('i18next-browser-languagedetector', () => ({
|
||||
default: testState.detectorPlugin,
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
initReactI18next: testState.reactPlugin,
|
||||
}));
|
||||
|
||||
describe('init-translations', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
testState.i18next.use.mockClear();
|
||||
testState.i18next.init.mockClear();
|
||||
testState.i18next.use.mockImplementation(() => testState.i18next);
|
||||
});
|
||||
|
||||
it('initializes i18next with the backend, detector, react plugin, and supported languages', async () => {
|
||||
await import('../init-translations');
|
||||
|
||||
expect(testState.i18next.use).toHaveBeenNthCalledWith(1, testState.backendPlugin);
|
||||
expect(testState.i18next.use).toHaveBeenNthCalledWith(2, testState.detectorPlugin);
|
||||
expect(testState.i18next.use).toHaveBeenNthCalledWith(3, testState.reactPlugin);
|
||||
expect(testState.i18next.init).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fallbackLng: 'en',
|
||||
ns: ['default'],
|
||||
defaultNS: 'default',
|
||||
backend: { loadPath: './translations/{{lng}}/{{ns}}.json' },
|
||||
}),
|
||||
);
|
||||
expect(testState.i18next.init.mock.calls[0]?.[0]?.supportedLngs).toContain('en');
|
||||
expect(testState.i18next.init.mock.calls[0]?.[0]?.supportedLngs).toContain('ja');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('snow', () => {
|
||||
beforeEach(() => {
|
||||
document.head.innerHTML = '';
|
||||
document.body.innerHTML = '';
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('creates and removes a deterministic snow field', async () => {
|
||||
const mathRandomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.5);
|
||||
const { initSnow, removeSnow } = await import('../snow');
|
||||
|
||||
initSnow({ flakeCount: 3 });
|
||||
|
||||
const snowfield = document.getElementById('js-snowfield');
|
||||
expect(snowfield).toBeTruthy();
|
||||
expect(snowfield?.children).toHaveLength(2);
|
||||
expect(document.head.querySelector('style')?.textContent).toContain('fall-1');
|
||||
|
||||
removeSnow();
|
||||
expect(document.getElementById('js-snowfield')).toBeNull();
|
||||
|
||||
mathRandomSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('prefers the special theme store and otherwise falls back to christmas dates', async () => {
|
||||
const useSpecialThemeStore = (await import('../../stores/use-special-theme-store')).default;
|
||||
const { shouldShowSnow } = await import('../snow');
|
||||
|
||||
useSpecialThemeStore.setState({ isEnabled: true });
|
||||
expect(shouldShowSnow()).toBe(true);
|
||||
|
||||
useSpecialThemeStore.setState({ isEnabled: false });
|
||||
expect(shouldShowSnow()).toBe(false);
|
||||
|
||||
useSpecialThemeStore.setState({ isEnabled: null });
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2024-12-24T00:00:00Z'));
|
||||
expect(shouldShowSnow()).toBe(true);
|
||||
|
||||
vi.setSystemTime(new Date('2024-07-04T00:00:00Z'));
|
||||
expect(shouldShowSnow()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('update-favicon', () => {
|
||||
beforeEach(() => {
|
||||
document.head.innerHTML = '';
|
||||
document.body.innerHTML = '';
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('creates a favicon link and replaces it only when the target icon changes', async () => {
|
||||
const { updateFavicon } = await import('../update-favicon');
|
||||
|
||||
updateFavicon(false);
|
||||
expect(document.querySelectorAll('link[rel="icon"]')).toHaveLength(1);
|
||||
expect(document.querySelector('link[rel="icon"]')?.getAttribute('href')).toBe('/favicon.ico');
|
||||
|
||||
updateFavicon(false);
|
||||
expect(document.querySelectorAll('link[rel="icon"]')).toHaveLength(1);
|
||||
|
||||
updateFavicon(true);
|
||||
expect(document.querySelectorAll('link[rel="icon"]')).toHaveLength(1);
|
||||
expect(document.querySelector('link[rel="icon"]')?.getAttribute('href')).toBe('/favicon2.ico');
|
||||
});
|
||||
|
||||
it('marks only non-special, non-routing aggregate sfw boards as sfw', async () => {
|
||||
const { isSfwBoard } = await import('../update-favicon');
|
||||
|
||||
expect(
|
||||
isSfwBoard({
|
||||
pathname: '/',
|
||||
isSpecialTheme: false,
|
||||
isInAllView: false,
|
||||
isInSubscriptionsView: false,
|
||||
isInModView: false,
|
||||
subplebbitAddress: 'music.eth',
|
||||
directories: [{ address: 'music.eth', nsfw: false }],
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
isSfwBoard({
|
||||
pathname: '/music.eth',
|
||||
isSpecialTheme: false,
|
||||
isInAllView: false,
|
||||
isInSubscriptionsView: false,
|
||||
isInModView: false,
|
||||
subplebbitAddress: 'music.eth',
|
||||
directories: [
|
||||
{ address: 'music.eth', nsfw: false },
|
||||
{ address: 'flash.eth', nsfw: true },
|
||||
],
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isSfwBoard({
|
||||
pathname: '/flash.eth',
|
||||
isSpecialTheme: false,
|
||||
isInAllView: false,
|
||||
isInSubscriptionsView: false,
|
||||
isInModView: false,
|
||||
subplebbitAddress: 'flash.eth',
|
||||
directories: [{ address: 'flash.eth', nsfw: true }],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { BLOTTER_PREVIEW_COUNT, formatBlotterDate, getBlotterPreview, isBlotterEntry, sortBlotterEntries } from '../blotter-utils';
|
||||
|
||||
describe('blotter-utils', () => {
|
||||
it('validates blotter entries and rejects malformed release entries', () => {
|
||||
expect(
|
||||
isBlotterEntry({
|
||||
id: 'release-1',
|
||||
kind: 'release',
|
||||
timestamp: 1_700_000_000,
|
||||
message: 'Released',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(isBlotterEntry({ id: '', kind: 'manual', timestamp: 1, message: 'note' })).toBe(false);
|
||||
expect(isBlotterEntry({ id: 'manual-1', kind: 'release', timestamp: 1, message: 'missing version' })).toBe(false);
|
||||
expect(isBlotterEntry({ id: 'manual-2', kind: 'manual', timestamp: -1, message: 'bad time' })).toBe(false);
|
||||
});
|
||||
|
||||
it('sorts entries descending by timestamp and slices previews', () => {
|
||||
const entries = [
|
||||
{ id: 'a', timestamp: 10 },
|
||||
{ id: 'b', timestamp: 30 },
|
||||
{ id: 'c', timestamp: 20 },
|
||||
{ id: 'd', timestamp: 5 },
|
||||
];
|
||||
|
||||
expect(sortBlotterEntries(entries).map((entry) => entry.id)).toEqual(['b', 'c', 'a', 'd']);
|
||||
expect(getBlotterPreview(entries).length).toBe(BLOTTER_PREVIEW_COUNT);
|
||||
expect(getBlotterPreview(entries, 2).map((entry) => entry.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('formats unix timestamps and rejects invalid values', () => {
|
||||
expect(formatBlotterDate(1_704_153_600)).toBe('01/02/24');
|
||||
expect(formatBlotterDate(-1)).toBe('');
|
||||
expect(formatBlotterDate(Number.NaN)).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { copyToClipboard } from '../clipboard-utils';
|
||||
import { hashStringToColor, getTextColorForBackground, removeMarkdown } from '../post-utils';
|
||||
import { preloadThemeAssets } from '../preload-utils';
|
||||
import { computeOmittedCount, getPreviewDisplayReplies, getTotalReplyCount } from '../replies-preview-utils';
|
||||
import { getQuotedCidsFromContent, mergeQuotedCids } from '../reply-quote-utils';
|
||||
import { formatUserIDForDisplay, truncateWithEllipsisInMiddle } from '../string-utils';
|
||||
import { getFormattedDate, getFormattedTimeAgo, isChristmas } from '../time-utils';
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
language: 'en',
|
||||
translateMock: vi.fn((key: string, options?: { count?: number }) => (typeof options?.count === 'number' ? `${key}:${options.count}` : key)),
|
||||
}));
|
||||
|
||||
vi.mock('i18next', () => ({
|
||||
default: {
|
||||
get language() {
|
||||
return testState.language;
|
||||
},
|
||||
t: (key: string, options?: { count?: number }) => testState.translateMock(key, options),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../generated/asset-manifest', () => ({
|
||||
THEME_BUTTON_IMAGES: ['buttons/default.png', 'buttons/hover.png'],
|
||||
THEME_BACKGROUND_IMAGES: ['backgrounds/wallpaper.png'],
|
||||
}));
|
||||
|
||||
describe('misc utils', () => {
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let dateTimeFormatSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testState.language = 'en';
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
dateTimeFormatSpy = vi.spyOn(Intl, 'DateTimeFormat').mockImplementation(function MockDateTimeFormat(..._args: unknown[]) {
|
||||
return {
|
||||
format: () => '01/02/24, Tue, 03:04:05',
|
||||
} as Intl.DateTimeFormat;
|
||||
} as unknown as typeof Intl.DateTimeFormat);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
dateTimeFormatSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('copies through Electron first and falls back to the browser clipboard on Electron failure', async () => {
|
||||
const electronCopy = vi.fn().mockResolvedValue({ success: true });
|
||||
const webCopy = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
Object.defineProperty(window, 'electronApi', {
|
||||
configurable: true,
|
||||
value: { copyToClipboard: electronCopy },
|
||||
});
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText: webCopy },
|
||||
});
|
||||
|
||||
await copyToClipboard('hello');
|
||||
expect(electronCopy).toHaveBeenCalledWith('hello');
|
||||
expect(webCopy).not.toHaveBeenCalled();
|
||||
|
||||
electronCopy.mockResolvedValueOnce({ success: false, error: 'denied' });
|
||||
await copyToClipboard('fallback');
|
||||
expect(webCopy).toHaveBeenCalledWith('fallback');
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Electron clipboard failed:', expect.any(Error));
|
||||
});
|
||||
|
||||
it('throws clear clipboard errors when no supported API succeeds', async () => {
|
||||
Object.defineProperty(window, 'electronApi', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText: vi.fn().mockRejectedValue(new Error('blocked')) },
|
||||
});
|
||||
|
||||
await expect(copyToClipboard('hello')).rejects.toThrow('Failed to copy to clipboard. Your browser may not support this feature.');
|
||||
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
await expect(copyToClipboard('hello')).rejects.toThrow('Your browser does not support clipboard API');
|
||||
});
|
||||
|
||||
it('formats post colors and strips markdown syntax while keeping user-visible text', () => {
|
||||
expect(hashStringToColor('abc')).toMatch(/^rgb\(-?\d+, -?\d+, -?\d+\)$/);
|
||||
expect(hashStringToColor('')).toBe('');
|
||||
expect(getTextColorForBackground('rgb(255, 255, 255)')).toBe('black');
|
||||
expect(getTextColorForBackground('rgb(10, 10, 10)')).toBe('white');
|
||||
expect(removeMarkdown('[spoiler]secret[/spoiler]\n>greentext\n**bold** [label](https://example.com) `code` ```block``` ')).toBe(
|
||||
'secret\ngreentext\nbold label code block',
|
||||
);
|
||||
});
|
||||
|
||||
it('preloads theme asset URLs through Image instances', () => {
|
||||
const loadedSources: string[] = [];
|
||||
|
||||
class FakeImage {
|
||||
set src(value: string) {
|
||||
loadedSources.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, 'Image', {
|
||||
configurable: true,
|
||||
value: FakeImage,
|
||||
});
|
||||
|
||||
preloadThemeAssets();
|
||||
|
||||
expect(loadedSources).toEqual(['/buttons/default.png', '/buttons/hover.png', '/backgrounds/wallpaper.png']);
|
||||
});
|
||||
|
||||
it('builds reply previews, omitted counts, and fallback reply totals', () => {
|
||||
expect(
|
||||
getPreviewDisplayReplies(
|
||||
[
|
||||
{ cid: 'old', timestamp: 1 },
|
||||
{ cid: 'pending', pendingApproval: true },
|
||||
{ cid: 'middle', timestamp: 5 },
|
||||
{ cid: 'draft', index: 99 },
|
||||
{ cid: 'newest', timestamp: 10 },
|
||||
],
|
||||
4,
|
||||
),
|
||||
).toEqual([
|
||||
{ cid: 'middle', timestamp: 5 },
|
||||
{ cid: 'newest', timestamp: 10 },
|
||||
{ cid: 'draft', index: 99 },
|
||||
{ cid: 'pending', pendingApproval: true },
|
||||
]);
|
||||
|
||||
expect(computeOmittedCount({ totalReplyCount: 2, visibleCount: 5 })).toBe(0);
|
||||
expect(computeOmittedCount({ totalReplyCount: 9, visibleCount: 5 })).toBe(4);
|
||||
expect(getTotalReplyCount({ replyCount: undefined, fullLoadedCount: 7, previewLoadedCount: 5 })).toBe(7);
|
||||
expect(getTotalReplyCount({ replyCount: 12, fullLoadedCount: 7, previewLoadedCount: 5 })).toBe(12);
|
||||
});
|
||||
|
||||
it('extracts quoted cids and merges quoted cid payloads without duplicates', () => {
|
||||
expect(getQuotedCidsFromContent('>>12 >>45 >>12', { 12: 'cid-12', 45: 'cid-45' })).toEqual(['cid-12', 'cid-45']);
|
||||
expect(getQuotedCidsFromContent(undefined, { 12: 'cid-12' })).toBeUndefined();
|
||||
expect(
|
||||
mergeQuotedCids(
|
||||
{
|
||||
author: { address: '0x123' },
|
||||
quotedCids: ['cid-12'],
|
||||
},
|
||||
['cid-12', 'cid-45'],
|
||||
),
|
||||
).toEqual({
|
||||
author: { address: '0x123' },
|
||||
quotedCids: ['cid-12', 'cid-45'],
|
||||
});
|
||||
expect(mergeQuotedCids(undefined, ['cid-12'])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('formats ids, truncates long strings, and localizes time labels', () => {
|
||||
expect(formatUserIDForDisplay('board.eth')).toBe('board.eth');
|
||||
expect(formatUserIDForDisplay('averyverylongdomainname.eth', 12)).toBe('averyvery...');
|
||||
expect(formatUserIDForDisplay('0123456789abcdef')).toBe('01234567');
|
||||
expect(truncateWithEllipsisInMiddle('abcdefghijklmnopqrstuvwxyz', 10)).toBe('abc...xyz');
|
||||
expect(truncateWithEllipsisInMiddle('abc', 10)).toBe('abc');
|
||||
|
||||
expect(getFormattedDate(1_704_153_600)).toBe('01/02/24(Tue)03:04:05');
|
||||
testState.language = 'ar';
|
||||
expect(getFormattedDate(1_704_153_600)).toBe('01/02/24, Tue, 03:04:05');
|
||||
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2024-01-02T00:00:00Z'));
|
||||
|
||||
expect(getFormattedTimeAgo(1_704_153_540)).toBe('time_1_minute_ago');
|
||||
expect(getFormattedTimeAgo(1_704_153_200)).toBe('time_x_minutes_ago:6');
|
||||
expect(getFormattedTimeAgo(1_704_146_800)).toBe('time_1_hour_ago');
|
||||
expect(getFormattedTimeAgo(1_704_139_200)).toBe('time_x_hours_ago:4');
|
||||
expect(getFormattedTimeAgo(1_704_067_200)).toBe('time_1_day_ago');
|
||||
expect(getFormattedTimeAgo(1_703_980_800)).toBe('time_x_days_ago:2');
|
||||
expect(getFormattedTimeAgo(1_701_561_600)).toBe('time_1_month_ago');
|
||||
expect(getFormattedTimeAgo(1_698_969_600)).toBe('time_x_months_ago:2');
|
||||
expect(getFormattedTimeAgo(1_672_617_600)).toBe('time_1_year_ago');
|
||||
expect(getFormattedTimeAgo(1_641_081_600)).toBe('time_x_years_ago:2');
|
||||
|
||||
vi.setSystemTime(new Date('2024-12-24T00:00:00Z'));
|
||||
expect(isChristmas()).toBe(true);
|
||||
|
||||
vi.setSystemTime(new Date('2024-07-04T00:00:00Z'));
|
||||
expect(isChristmas()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
copyToClipboardMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../clipboard-utils', () => ({
|
||||
copyToClipboard: (text: string) => testState.copyToClipboardMock(text),
|
||||
}));
|
||||
|
||||
import { copyShareLinkToClipboard, getHostname, is5chanLink, isValidCrossboardPattern, isValidURL, transform5chanLinkToInternal } from '../url-utils';
|
||||
|
||||
describe('url-utils', () => {
|
||||
beforeEach(() => {
|
||||
testState.copyToClipboardMock.mockReset();
|
||||
});
|
||||
|
||||
it('extracts hostnames and validates urls', () => {
|
||||
expect(getHostname('https://www.5chan.app/#/music.eth')).toBe('5chan.app');
|
||||
expect(getHostname('not-a-url')).toBe('');
|
||||
expect(isValidURL('https://5chan.app')).toBe(true);
|
||||
expect(isValidURL('not-a-url')).toBe(false);
|
||||
});
|
||||
|
||||
it('copies share links for threads and catalog pages using the production fallback base url', async () => {
|
||||
await copyShareLinkToClipboard('music.eth', 'thread', 'cid-123');
|
||||
expect(testState.copyToClipboardMock).toHaveBeenCalledWith('https://5chan.app/#/music.eth/thread/cid-123');
|
||||
|
||||
await copyShareLinkToClipboard('music.eth', 'catalog');
|
||||
expect(testState.copyToClipboardMock).toHaveBeenCalledWith('https://5chan.app/#/music.eth/catalog');
|
||||
|
||||
const copyThreadWithoutCid = copyShareLinkToClipboard as (boardIdentifier: string, linkType: 'thread', cid?: string) => Promise<void>;
|
||||
await expect(copyThreadWithoutCid('music.eth', 'thread')).rejects.toThrow('copyShareLinkToClipboard: thread links require a cid');
|
||||
});
|
||||
|
||||
it('recognizes supported 5chan urls and rejects unrelated domains', () => {
|
||||
expect(is5chanLink('https://5chan.app/music.eth')).toBe(true);
|
||||
expect(is5chanLink('https://5chan.app/music.eth/thread/cid-123')).toBe(true);
|
||||
expect(is5chanLink('https://5chan.app/#/music.eth/catalog')).toBe(true);
|
||||
expect(is5chanLink('https://5chan.app/p/music.eth/c/cid-123')).toBe(true);
|
||||
expect(is5chanLink('https://5chan.app/all/catalog')).toBe(true);
|
||||
expect(is5chanLink('https://example.com/music.eth')).toBe(false);
|
||||
});
|
||||
|
||||
it('transforms legacy and hash-based share links into internal routes', () => {
|
||||
expect(transform5chanLinkToInternal('https://5chan.app/p/music.eth/c/cid-123?redirect=https://example.com')).toBe('/music.eth/thread/cid-123');
|
||||
expect(transform5chanLinkToInternal('https://5chan.app/p/music.eth?foo=1')).toBe('/music.eth?foo=1');
|
||||
expect(transform5chanLinkToInternal('https://5chan.app/#/music.eth/catalog')).toBe('/music.eth/catalog');
|
||||
expect(transform5chanLinkToInternal('https://example.com/music.eth')).toBeNull();
|
||||
});
|
||||
|
||||
it('validates cross-board quote patterns for board codes, domains, and ipns keys', () => {
|
||||
const ipnsKey = `12D3KooW${'a'.repeat(44)}`;
|
||||
|
||||
expect(isValidCrossboardPattern('>>>/biz/')).toBe(true);
|
||||
expect(isValidCrossboardPattern(`>>>/biz/${'a'.repeat(46)}`)).toBe(true);
|
||||
expect(isValidCrossboardPattern(`>>>/board.eth/${'b'.repeat(46)}`)).toBe(true);
|
||||
expect(isValidCrossboardPattern(`>>>/${ipnsKey}`)).toBe(true);
|
||||
expect(isValidCrossboardPattern('>>>/invalid/thread-with-short-cid')).toBe(false);
|
||||
expect(isValidCrossboardPattern('>>/biz/')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
isAllView,
|
||||
isBoardView,
|
||||
isCatalogView,
|
||||
isHomeView,
|
||||
isModQueueView,
|
||||
isModView,
|
||||
isNotFoundView,
|
||||
isPendingPostView,
|
||||
isPostPageView,
|
||||
isSettingsView,
|
||||
isSubscriptionsView,
|
||||
} from '../view-utils';
|
||||
|
||||
describe('view-utils', () => {
|
||||
it('classifies aggregate routes and special app views', () => {
|
||||
expect(isAllView('/all')).toBe(true);
|
||||
expect(isHomeView('/')).toBe(true);
|
||||
expect(isModView('/mod/queue')).toBe(true);
|
||||
expect(isModQueueView('/music.eth/mod/queue')).toBe(true);
|
||||
expect(isSubscriptionsView('/subs/catalog/settings', {})).toBe(true);
|
||||
expect(isPendingPostView('/pending/42/settings', { accountCommentIndex: '42' })).toBe(true);
|
||||
});
|
||||
|
||||
it('detects board, catalog, post, and settings routes using board params', () => {
|
||||
const params = {
|
||||
boardIdentifier: 'music.eth',
|
||||
commentCid: 'cid-123',
|
||||
accountCommentIndex: '42',
|
||||
};
|
||||
|
||||
expect(isBoardView('/music.eth', params)).toBe(true);
|
||||
expect(isBoardView('/all', params)).toBe(false);
|
||||
expect(isCatalogView('/music.eth/catalog', params)).toBe(true);
|
||||
expect(isPostPageView('/music.eth/thread/cid-123', params)).toBe(true);
|
||||
expect(isSettingsView('/music.eth/thread/cid-123/settings', params)).toBe(true);
|
||||
});
|
||||
|
||||
it('supports deprecated subplebbitAddress params and marks unknown routes as not found', () => {
|
||||
const params = {
|
||||
subplebbitAddress: 'emoji-🎵.eth',
|
||||
commentCid: 'cid-123',
|
||||
};
|
||||
|
||||
expect(isBoardView('/emoji-%F0%9F%8E%B5.eth', params)).toBe(true);
|
||||
expect(isCatalogView('/emoji-%F0%9F%8E%B5.eth/catalog/settings', params)).toBe(true);
|
||||
expect(isPostPageView('/emoji-%F0%9F%8E%B5.eth/thread/cid-123', params)).toBe(true);
|
||||
expect(isNotFoundView('/definitely-not-a-route', params)).toBe(true);
|
||||
expect(isNotFoundView('/emoji-%F0%9F%8E%B5.eth/thread/cid-123', params)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import useChallengesStore from '../use-challenges-store';
|
||||
import useCreateBoardModalStore from '../use-create-board-modal-store';
|
||||
import useDirectoryModalStore from '../use-directory-modal-store';
|
||||
import useDisclaimerModalStore, { DISCLAIMER_ACCEPTED_KEY } from '../use-disclaimer-modal-store';
|
||||
import useFeedResetStore from '../use-feed-reset-store';
|
||||
import usePostNumberStore from '../use-post-number-store';
|
||||
import useReplyModalStore from '../use-reply-modal-store';
|
||||
import useSelectedTextStore from '../use-selected-text-store';
|
||||
import useSortingStore from '../use-sorting-store';
|
||||
|
||||
const resetReplyModalStore = () => {
|
||||
useReplyModalStore.setState({
|
||||
showReplyModal: false,
|
||||
openEmpty: false,
|
||||
activeCid: null,
|
||||
parentNumber: null,
|
||||
threadNumber: null,
|
||||
threadCid: null,
|
||||
subplebbitAddress: null,
|
||||
scrollY: 0,
|
||||
quoteInsertRequestId: 0,
|
||||
quoteInsertNumber: null,
|
||||
quoteInsertSelectedText: null,
|
||||
});
|
||||
};
|
||||
|
||||
describe('interaction stores', () => {
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
useChallengesStore.setState({ challenges: [] });
|
||||
useCreateBoardModalStore.getState().closeCreateBoardModal();
|
||||
useDirectoryModalStore.getState().closeDirectoryModal();
|
||||
useDisclaimerModalStore.getState().closeDisclaimerModal();
|
||||
useFeedResetStore.setState({ reset: null });
|
||||
usePostNumberStore.setState({ numberToCid: {}, cidToNumber: {} });
|
||||
useSelectedTextStore.getState().resetSelectedText();
|
||||
useSortingStore.getState().setSortType('active');
|
||||
resetReplyModalStore();
|
||||
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
value: 1024,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(window, 'scrollY', {
|
||||
configurable: true,
|
||||
value: 0,
|
||||
writable: true,
|
||||
});
|
||||
vi.spyOn(document, 'getSelection').mockReturnValue({ toString: () => '' } as Selection);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
vi.restoreAllMocks();
|
||||
useSelectedTextStore.getState().resetSelectedText();
|
||||
resetReplyModalStore();
|
||||
});
|
||||
|
||||
it('opens and closes basic modal stores and keeps reset callbacks addressable', () => {
|
||||
expect(useCreateBoardModalStore.getState().showModal).toBe(false);
|
||||
useCreateBoardModalStore.getState().openCreateBoardModal();
|
||||
expect(useCreateBoardModalStore.getState().showModal).toBe(true);
|
||||
useCreateBoardModalStore.getState().closeCreateBoardModal();
|
||||
expect(useCreateBoardModalStore.getState().showModal).toBe(false);
|
||||
|
||||
expect(useDirectoryModalStore.getState().showModal).toBe(false);
|
||||
useDirectoryModalStore.getState().openDirectoryModal();
|
||||
expect(useDirectoryModalStore.getState().showModal).toBe(true);
|
||||
useDirectoryModalStore.getState().closeDirectoryModal();
|
||||
expect(useDirectoryModalStore.getState().showModal).toBe(false);
|
||||
|
||||
const resetMock = vi.fn();
|
||||
useFeedResetStore.getState().setResetFunction(resetMock);
|
||||
useFeedResetStore.getState().reset?.();
|
||||
expect(resetMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(useSortingStore.getState().sortType).toBe('active');
|
||||
useSortingStore.getState().setSortType('replyCount');
|
||||
expect(useSortingStore.getState().sortType).toBe('replyCount');
|
||||
});
|
||||
|
||||
it('queues challenges, abandons the current one, and logs abandon failures', async () => {
|
||||
const abandonMock = vi.fn().mockResolvedValue(undefined);
|
||||
const failingAbandonMock = vi.fn().mockRejectedValue(new Error('stop failed'));
|
||||
|
||||
useChallengesStore.getState().addChallenge({ type: 'captcha' } as never, abandonMock);
|
||||
useChallengesStore.getState().addChallenge({ type: 'math' } as never, failingAbandonMock);
|
||||
|
||||
const [first, second] = useChallengesStore.getState().challenges;
|
||||
expect(first.challenge).toEqual({ type: 'captcha' });
|
||||
expect(second.challenge).toEqual({ type: 'math' });
|
||||
expect(first.id).not.toBe(second.id);
|
||||
|
||||
await useChallengesStore.getState().abandonCurrentChallenge();
|
||||
expect(abandonMock).toHaveBeenCalledTimes(1);
|
||||
expect(useChallengesStore.getState().challenges).toHaveLength(1);
|
||||
|
||||
await useChallengesStore.getState().abandonCurrentChallenge();
|
||||
expect(failingAbandonMock).toHaveBeenCalledTimes(1);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to abandon challenge publication:', expect.any(Error));
|
||||
expect(useChallengesStore.getState().challenges).toHaveLength(0);
|
||||
|
||||
useChallengesStore.getState().addChallenge({ type: 'again' } as never);
|
||||
useChallengesStore.getState().removeChallenge();
|
||||
expect(useChallengesStore.getState().challenges).toEqual([]);
|
||||
});
|
||||
|
||||
it('shows the disclaimer modal until accepted, then navigates directly on later opens', () => {
|
||||
const navigate = vi.fn();
|
||||
|
||||
useDisclaimerModalStore.getState().showDisclaimerModal('board.eth', navigate, 'biz');
|
||||
|
||||
expect(useDisclaimerModalStore.getState()).toMatchObject({
|
||||
showModal: true,
|
||||
targetAddress: 'board.eth',
|
||||
targetBoardPath: 'biz',
|
||||
});
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
|
||||
useDisclaimerModalStore.getState().acceptDisclaimer(navigate);
|
||||
expect(localStorage.getItem(DISCLAIMER_ACCEPTED_KEY)).toBe('true');
|
||||
expect(navigate).toHaveBeenCalledWith('/biz');
|
||||
expect(useDisclaimerModalStore.getState().showModal).toBe(false);
|
||||
|
||||
navigate.mockClear();
|
||||
useDisclaimerModalStore.getState().showDisclaimerModal('music-posting.eth', navigate);
|
||||
expect(useDisclaimerModalStore.getState().showModal).toBe(false);
|
||||
expect(navigate).toHaveBeenCalledWith('/music-posting.eth');
|
||||
});
|
||||
|
||||
it('still navigates when saving disclaimer acceptance fails', () => {
|
||||
const navigate = vi.fn();
|
||||
const setItemSpy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('storage is locked');
|
||||
});
|
||||
|
||||
useDisclaimerModalStore.getState().showDisclaimerModal('board.eth', navigate, 'board-path');
|
||||
useDisclaimerModalStore.getState().acceptDisclaimer(navigate);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to save disclaimer acceptance to localStorage:', expect.any(Error));
|
||||
expect(navigate).toHaveBeenCalledWith('/board-path');
|
||||
expect(useDisclaimerModalStore.getState().showModal).toBe(false);
|
||||
|
||||
setItemSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('registers post numbers per board and ignores unchanged or invalid comments', () => {
|
||||
const comments = [
|
||||
{ cid: 'cid-1', number: 1, subplebbitAddress: 'music.eth' },
|
||||
{ cid: 'cid-2', number: 2, subplebbitAddress: 'music.eth' },
|
||||
{ cid: 'cid-1-tech', number: 1, subplebbitAddress: 'tech.eth' },
|
||||
{ cid: '', number: 3, subplebbitAddress: 'music.eth' },
|
||||
{ cid: 'cid-no-number', subplebbitAddress: 'music.eth' },
|
||||
] as never[];
|
||||
|
||||
usePostNumberStore.getState().registerComments(comments);
|
||||
|
||||
const firstState = usePostNumberStore.getState();
|
||||
expect(firstState.numberToCid).toEqual({
|
||||
'music.eth': { 1: 'cid-1', 2: 'cid-2' },
|
||||
'tech.eth': { 1: 'cid-1-tech' },
|
||||
});
|
||||
expect(firstState.cidToNumber).toEqual({
|
||||
'cid-1': 1,
|
||||
'cid-2': 2,
|
||||
'cid-1-tech': 1,
|
||||
});
|
||||
|
||||
const numberToCidRef = firstState.numberToCid;
|
||||
usePostNumberStore.getState().registerComments(comments);
|
||||
expect(usePostNumberStore.getState().numberToCid).toBe(numberToCidRef);
|
||||
});
|
||||
|
||||
it('opens reply modals with quoted selection and mobile scroll state', () => {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
value: 600,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(window, 'scrollY', {
|
||||
configurable: true,
|
||||
value: 140,
|
||||
writable: true,
|
||||
});
|
||||
vi.spyOn(document, 'getSelection').mockReturnValue({ toString: () => 'alpha\nbeta\n' } as Selection);
|
||||
|
||||
useReplyModalStore.getState().openReplyModal('parent-cid', 12, 'thread-cid', 34, 'music.eth');
|
||||
|
||||
expect(useSelectedTextStore.getState().selectedText).toBe('>alpha\n>beta\n');
|
||||
expect(useReplyModalStore.getState()).toMatchObject({
|
||||
showReplyModal: true,
|
||||
openEmpty: false,
|
||||
activeCid: 'thread-cid',
|
||||
parentNumber: 12,
|
||||
threadNumber: 34,
|
||||
threadCid: 'thread-cid',
|
||||
subplebbitAddress: 'music.eth',
|
||||
scrollY: 140,
|
||||
});
|
||||
});
|
||||
|
||||
it('inserts quote requests into an already-open reply modal and can reopen empty', () => {
|
||||
useReplyModalStore.getState().openReplyModal('parent-cid', 12, 'thread-cid', 34, 'music.eth');
|
||||
vi.spyOn(document, 'getSelection').mockReturnValue({ toString: () => 'quoted text' } as Selection);
|
||||
|
||||
useReplyModalStore.getState().openReplyModal('parent-cid-2', 77, 'thread-cid', 34, 'music.eth');
|
||||
|
||||
expect(useReplyModalStore.getState().quoteInsertRequestId).toBe(1);
|
||||
expect(useReplyModalStore.getState().quoteInsertNumber).toBe(77);
|
||||
expect(useReplyModalStore.getState().quoteInsertSelectedText).toBe('>quoted text');
|
||||
|
||||
useSelectedTextStore.getState().setSelectedText('stale quote');
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
value: 500,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(window, 'scrollY', {
|
||||
configurable: true,
|
||||
value: 32,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
useReplyModalStore.getState().openReplyModalEmpty('thread-cid', 34, 'music.eth');
|
||||
|
||||
expect(useSelectedTextStore.getState().selectedText).toBe('');
|
||||
expect(useReplyModalStore.getState()).toMatchObject({
|
||||
showReplyModal: true,
|
||||
openEmpty: true,
|
||||
activeCid: 'thread-cid',
|
||||
threadNumber: 34,
|
||||
threadCid: 'thread-cid',
|
||||
subplebbitAddress: 'music.eth',
|
||||
scrollY: 32,
|
||||
quoteInsertNumber: null,
|
||||
quoteInsertSelectedText: null,
|
||||
});
|
||||
|
||||
useSelectedTextStore.getState().setSelectedText('cleanup');
|
||||
useReplyModalStore.getState().closeModal();
|
||||
expect(useSelectedTextStore.getState().selectedText).toBe('');
|
||||
expect(useReplyModalStore.getState()).toMatchObject({
|
||||
showReplyModal: false,
|
||||
openEmpty: false,
|
||||
activeCid: null,
|
||||
parentNumber: null,
|
||||
threadNumber: null,
|
||||
quoteInsertNumber: null,
|
||||
quoteInsertSelectedText: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const flushMicrotasks = async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
};
|
||||
|
||||
const loadBlotterVisibilityStore = async () => (await import('../use-blotter-visibility-store')).default;
|
||||
const loadModQueueStore = async () => (await import('../use-mod-queue-store')).default;
|
||||
const loadPopularThreadsOptionsStore = async () => (await import('../use-popular-threads-options-store')).default;
|
||||
|
||||
const loadSpecialThemeStore = async (isChristmas: boolean) => {
|
||||
vi.resetModules();
|
||||
vi.doMock('../../lib/utils/time-utils', () => ({
|
||||
isChristmas: () => isChristmas,
|
||||
}));
|
||||
const module = await import('../use-special-theme-store');
|
||||
await flushMicrotasks();
|
||||
return module.default;
|
||||
};
|
||||
|
||||
describe('persisted extra stores', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.doUnmock('../../lib/utils/time-utils');
|
||||
});
|
||||
|
||||
it('toggles blotter visibility and persists the hidden flag', async () => {
|
||||
const setItemSpy = vi.spyOn(Storage.prototype, 'setItem');
|
||||
const useBlotterVisibilityStore = await loadBlotterVisibilityStore();
|
||||
|
||||
expect(useBlotterVisibilityStore.getState().isHidden).toBe(false);
|
||||
|
||||
useBlotterVisibilityStore.getState().toggleVisibility();
|
||||
expect(useBlotterVisibilityStore.getState().isHidden).toBe(true);
|
||||
expect(setItemSpy).toHaveBeenCalledWith('blotter-visibility', expect.stringContaining('"isHidden":true'));
|
||||
|
||||
setItemSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('loads popular thread options from localStorage defaults and persists changes', async () => {
|
||||
const usePopularThreadsOptionsStore = await loadPopularThreadsOptionsStore();
|
||||
|
||||
expect(usePopularThreadsOptionsStore.getState().showWorksafeContentOnly).toBe(true);
|
||||
expect(usePopularThreadsOptionsStore.getState().showNsfwContentOnly).toBe(false);
|
||||
|
||||
usePopularThreadsOptionsStore.getState().setShowWorksafeContentOnly(false);
|
||||
usePopularThreadsOptionsStore.getState().setShowNsfwContentOnly(true);
|
||||
|
||||
expect(localStorage.getItem('showWorksafeContentOnly')).toBe('false');
|
||||
expect(localStorage.getItem('showNsfwContentOnly')).toBe('true');
|
||||
});
|
||||
|
||||
it('migrates legacy mod queue storage and computes threshold seconds from the active unit', async () => {
|
||||
localStorage.setItem(
|
||||
'mod-queue-storage',
|
||||
JSON.stringify({
|
||||
state: {
|
||||
alertThresholdHours: 3,
|
||||
selectedBoardFilter: 'music.eth',
|
||||
viewMode: 'feed',
|
||||
},
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
const useModQueueStore = await loadModQueueStore();
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(useModQueueStore.getState()).toMatchObject({
|
||||
alertThresholdValue: 3,
|
||||
alertThresholdUnit: 'hours',
|
||||
selectedBoardFilter: 'music.eth',
|
||||
viewMode: 'feed',
|
||||
});
|
||||
expect(useModQueueStore.getState().getAlertThresholdSeconds()).toBe(10_800);
|
||||
|
||||
useModQueueStore.getState().setAlertThreshold(15, 'minutes');
|
||||
useModQueueStore.getState().setSelectedBoardFilter('tech.eth');
|
||||
useModQueueStore.getState().setViewMode('compact');
|
||||
|
||||
expect(useModQueueStore.getState()).toMatchObject({
|
||||
alertThresholdValue: 15,
|
||||
alertThresholdUnit: 'minutes',
|
||||
selectedBoardFilter: 'tech.eth',
|
||||
viewMode: 'compact',
|
||||
});
|
||||
expect(useModQueueStore.getState().getAlertThresholdSeconds()).toBe(900);
|
||||
});
|
||||
|
||||
it('blocks special theme enablement outside christmas and clears persisted enabled state on rehydrate', async () => {
|
||||
localStorage.setItem(
|
||||
'Special-theme-storage',
|
||||
JSON.stringify({
|
||||
state: { isEnabled: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
const useSpecialThemeStore = await loadSpecialThemeStore(false);
|
||||
|
||||
expect(useSpecialThemeStore.getState().isEnabled).toBeNull();
|
||||
|
||||
useSpecialThemeStore.getState().setIsEnabled(true);
|
||||
expect(useSpecialThemeStore.getState().isEnabled).toBeNull();
|
||||
});
|
||||
|
||||
it('allows opting into the special theme during christmas', async () => {
|
||||
const useSpecialThemeStore = await loadSpecialThemeStore(true);
|
||||
|
||||
expect(useSpecialThemeStore.getState().isEnabled).toBeNull();
|
||||
|
||||
useSpecialThemeStore.getState().setIsEnabled(true);
|
||||
expect(useSpecialThemeStore.getState().isEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getAllBoardCodes } from '../../constants/board-codes';
|
||||
|
||||
describe('preference stores', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('useAllFeedFilterStore loads and persists the selected filter', async () => {
|
||||
localStorage.setItem('5chan-all-feed-filter', 'nsfw');
|
||||
|
||||
const store = (await import('../use-all-feed-filter-store')).default;
|
||||
|
||||
expect(store.getState().filter).toBe('nsfw');
|
||||
|
||||
store.getState().setFilter('sfw');
|
||||
|
||||
expect(store.getState().filter).toBe('sfw');
|
||||
expect(localStorage.getItem('5chan-all-feed-filter')).toBe('sfw');
|
||||
});
|
||||
|
||||
it('useBoardsFilterStore restores board preferences and saves updates', async () => {
|
||||
localStorage.setItem('5chan-boards-use-catalog', 'true');
|
||||
localStorage.setItem('5chan-boards-filter', 'worksafe');
|
||||
|
||||
const store = (await import('../use-boards-filter-store')).default;
|
||||
|
||||
expect(store.getState().useCatalogLinks).toBe(true);
|
||||
expect(store.getState().boardFilter).toBe('worksafe');
|
||||
|
||||
store.getState().setUseCatalogLinks(false);
|
||||
store.getState().setBoardFilter('nsfw');
|
||||
|
||||
expect(store.getState().useCatalogLinks).toBe(false);
|
||||
expect(store.getState().boardFilter).toBe('nsfw');
|
||||
expect(localStorage.getItem('5chan-boards-use-catalog')).toBe('false');
|
||||
expect(localStorage.getItem('5chan-boards-filter')).toBe('nsfw');
|
||||
});
|
||||
|
||||
it('useCatalogStyleStore reads existing preferences and updates both settings', async () => {
|
||||
localStorage.setItem('imageSize', 'Large');
|
||||
localStorage.setItem('showOPComment', 'false');
|
||||
|
||||
const store = (await import('../use-catalog-style-store')).default;
|
||||
|
||||
expect(store.getState().imageSize).toBe('Large');
|
||||
expect(store.getState().showOPComment).toBe(false);
|
||||
|
||||
store.getState().setImageSize('Small');
|
||||
store.getState().setShowOPComment(true);
|
||||
|
||||
expect(store.getState().imageSize).toBe('Small');
|
||||
expect(store.getState().showOPComment).toBe(true);
|
||||
expect(localStorage.getItem('imageSize')).toBe('Small');
|
||||
expect(localStorage.getItem('showOPComment')).toBe('true');
|
||||
});
|
||||
|
||||
it('useBoardsBarVisibilityStore loads legacy keys and updates visibility settings', async () => {
|
||||
const boardCodes = getAllBoardCodes();
|
||||
localStorage.setItem('5chan-topbar-directories-visible', JSON.stringify([boardCodes[0]]));
|
||||
localStorage.setItem('5chan-topbar-subscriptions-visible', JSON.stringify(['sub-1']));
|
||||
|
||||
const store = (await import('../use-boards-bar-visibility-store')).default;
|
||||
|
||||
expect(Array.from(store.getState().visibleDirectories)).toEqual([boardCodes[0]]);
|
||||
expect(store.getState().showSubscriptionsInBoardsBar).toBe(true);
|
||||
|
||||
store.getState().toggleDirectory(boardCodes[1]);
|
||||
expect(store.getState().visibleDirectories.has(boardCodes[1])).toBe(true);
|
||||
|
||||
store.getState().setDirectoryVisibility(boardCodes[0], false);
|
||||
expect(store.getState().visibleDirectories.has(boardCodes[0])).toBe(false);
|
||||
|
||||
store.getState().setShowSubscriptionsInBoardsBar(false);
|
||||
expect(store.getState().showSubscriptionsInBoardsBar).toBe(false);
|
||||
expect(localStorage.getItem('5chan-boardsbar-subscriptions-visible')).toBe('false');
|
||||
});
|
||||
|
||||
it('useBoardsBarVisibilityStore reinitializes from current storage keys', async () => {
|
||||
const boardCodes = getAllBoardCodes();
|
||||
const store = (await import('../use-boards-bar-visibility-store')).default;
|
||||
|
||||
localStorage.setItem('5chan-boardsbar-directories-visible', JSON.stringify([boardCodes[2], boardCodes[3]]));
|
||||
localStorage.setItem('5chan-boardsbar-subscriptions-visible', JSON.stringify(true));
|
||||
|
||||
store.getState().initialize();
|
||||
|
||||
expect(Array.from(store.getState().visibleDirectories)).toEqual([boardCodes[2], boardCodes[3]]);
|
||||
expect(store.getState().showSubscriptionsInBoardsBar).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import usePublishPostStore from '../use-publish-post-store';
|
||||
import usePublishReplyStore from '../use-publish-reply-store';
|
||||
|
||||
type PublishPostInput = Parameters<ReturnType<typeof usePublishPostStore.getState>['setPublishPostStore']>[0];
|
||||
type PublishReplyInput = Parameters<ReturnType<typeof usePublishReplyStore.getState>['setPublishReplyStore']>[0];
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
alertChallengeVerificationFailedMock: vi.fn(),
|
||||
alertMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/utils/challenge-utils', () => ({
|
||||
alertChallengeVerificationFailed: (challengeVerification: unknown, comment: unknown) => testState.alertChallengeVerificationFailedMock(challengeVerification, comment),
|
||||
}));
|
||||
|
||||
describe('publish stores', () => {
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
testState.alertChallengeVerificationFailedMock.mockReset();
|
||||
testState.alertMock.mockReset();
|
||||
vi.stubGlobal('alert', testState.alertMock);
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
usePublishPostStore.getState().resetPublishPostStore();
|
||||
usePublishReplyStore.getState().resetPublishReplyStore('parent-1');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
vi.unstubAllGlobals();
|
||||
usePublishPostStore.getState().resetPublishPostStore();
|
||||
usePublishReplyStore.getState().resetPublishReplyStore('parent-1');
|
||||
});
|
||||
|
||||
it('usePublishPostStore derives display name, payload options, and resets cleanly', () => {
|
||||
const comment: PublishPostInput = {
|
||||
author: { address: '0x123', displayName: 'Author Name', role: 'mod' },
|
||||
content: 'post body',
|
||||
displayName: 'Poster Alias',
|
||||
link: 'https://example.com',
|
||||
spoiler: true,
|
||||
subplebbitAddress: 'music-posting.eth',
|
||||
title: 'Hello',
|
||||
};
|
||||
|
||||
usePublishPostStore.getState().setPublishPostStore(comment);
|
||||
|
||||
const state = usePublishPostStore.getState();
|
||||
expect(state.displayName).toBe('Poster Alias');
|
||||
expect(state.author).toEqual({ address: '0x123', role: 'mod', displayName: 'Poster Alias' });
|
||||
expect(state.publishCommentOptions.author).toEqual({ address: '0x123', role: 'mod', displayName: 'Poster Alias' });
|
||||
expect(state.publishCommentOptions.subplebbitAddress).toBe('music-posting.eth');
|
||||
expect(state.publishCommentOptions.title).toBe('Hello');
|
||||
|
||||
state.publishCommentOptions.onChallengeVerification?.({} as never, comment);
|
||||
expect(testState.alertChallengeVerificationFailedMock).toHaveBeenCalledWith({}, comment);
|
||||
|
||||
state.publishCommentOptions.onError?.(new Error('publish failed'));
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
expect(testState.alertMock).toHaveBeenCalledWith('publish failed');
|
||||
|
||||
state.resetPublishPostStore();
|
||||
expect(usePublishPostStore.getState().publishCommentOptions).toEqual({});
|
||||
expect(usePublishPostStore.getState().title).toBeUndefined();
|
||||
});
|
||||
|
||||
it('usePublishReplyStore stores reply data per parentCid and resets a single thread', () => {
|
||||
const comment: PublishReplyInput = {
|
||||
author: { address: '0x123', displayName: 'Author Name', role: 'mod' },
|
||||
content: 'reply body',
|
||||
displayName: 'Reply Alias',
|
||||
link: 'https://example.com/reply',
|
||||
parentCid: 'parent-1',
|
||||
spoiler: false,
|
||||
subplebbitAddress: 'music-posting.eth',
|
||||
};
|
||||
|
||||
usePublishReplyStore.getState().setPublishReplyStore(comment);
|
||||
|
||||
const state = usePublishReplyStore.getState();
|
||||
expect(state.displayName['parent-1']).toBe('Reply Alias');
|
||||
expect(state.author['parent-1']).toEqual({ address: '0x123', role: 'mod', displayName: 'Reply Alias' });
|
||||
expect(state.publishCommentOptions['parent-1']?.parentCid).toBe('parent-1');
|
||||
expect(state.publishCommentOptions['parent-1']?.postCid).toBe('parent-1');
|
||||
|
||||
state.publishCommentOptions['parent-1']?.onChallengeVerification?.({ token: 'challenge' } as never, comment);
|
||||
expect(testState.alertChallengeVerificationFailedMock).toHaveBeenCalledWith({ token: 'challenge' }, comment);
|
||||
|
||||
state.publishCommentOptions['parent-1']?.onError?.(new Error('reply failed'));
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
expect(testState.alertMock).toHaveBeenCalledWith('reply failed');
|
||||
|
||||
state.resetPublishReplyStore('parent-1');
|
||||
expect(usePublishReplyStore.getState().publishCommentOptions['parent-1']).toBeUndefined();
|
||||
expect(usePublishReplyStore.getState().content['parent-1']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
entriesMock: vi.fn(async () => [] as Array<['nsfw' | 'sfw', string]>),
|
||||
setItemMock: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock('@bitsocialhq/bitsocial-react-hooks/dist/lib/localforage-lru/index.js', () => ({
|
||||
default: {
|
||||
createInstance: () => ({
|
||||
entries: testState.entriesMock,
|
||||
setItem: testState.setItemMock,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const waitFor = async (predicate: () => boolean) => {
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
await Promise.resolve();
|
||||
if (predicate()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
describe('useThemeStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
testState.entriesMock.mockReset();
|
||||
testState.entriesMock.mockResolvedValue([]);
|
||||
testState.setItemMock.mockReset();
|
||||
testState.setItemMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('loads stored themes on initialization', async () => {
|
||||
testState.entriesMock.mockResolvedValue([
|
||||
['nsfw', 'tomorrow'],
|
||||
['sfw', 'photon'],
|
||||
]);
|
||||
|
||||
const store = (await import('../use-theme-store')).default;
|
||||
await waitFor(() => store.getState().themes.nsfw === 'tomorrow');
|
||||
|
||||
expect(store.getState().themes).toEqual({
|
||||
nsfw: 'tomorrow',
|
||||
sfw: 'photon',
|
||||
});
|
||||
expect(store.getState().currentTheme).toBeNull();
|
||||
});
|
||||
|
||||
it('setTheme persists the updated theme and updates currentTheme', async () => {
|
||||
const store = (await import('../use-theme-store')).default;
|
||||
await waitFor(() => testState.entriesMock.mock.calls.length > 0);
|
||||
|
||||
await store.getState().setTheme('nsfw', 'photon');
|
||||
|
||||
expect(testState.setItemMock).toHaveBeenCalledWith('nsfw', 'photon');
|
||||
expect(store.getState().themes.nsfw).toBe('photon');
|
||||
expect(store.getState().currentTheme).toBe('photon');
|
||||
});
|
||||
|
||||
it('getTheme can skip updating currentTheme when requested', async () => {
|
||||
const store = (await import('../use-theme-store')).default;
|
||||
await waitFor(() => testState.entriesMock.mock.calls.length > 0);
|
||||
|
||||
expect(store.getState().getTheme('sfw', false)).toBe('yotsuba-b');
|
||||
expect(store.getState().currentTheme).toBeNull();
|
||||
|
||||
expect(store.getState().getTheme('sfw')).toBe('yotsuba-b');
|
||||
expect(store.getState().currentTheme).toBe('yotsuba-b');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('ui state stores', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('useBoardsBarEditModalStore opens and closes the modal', async () => {
|
||||
const store = (await import('../use-boards-bar-edit-modal-store')).default;
|
||||
|
||||
expect(store.getState().showModal).toBe(false);
|
||||
|
||||
store.getState().openBoardsBarEditModal();
|
||||
expect(store.getState().showModal).toBe(true);
|
||||
|
||||
store.getState().closeBoardsBarEditModal();
|
||||
expect(store.getState().showModal).toBe(false);
|
||||
});
|
||||
|
||||
it('useSelectedTextStore sets and resets selected text', async () => {
|
||||
const store = (await import('../use-selected-text-store')).default;
|
||||
|
||||
store.getState().setSelectedText('>quoted line');
|
||||
expect(store.getState().selectedText).toBe('>quoted line');
|
||||
|
||||
store.getState().resetSelectedText();
|
||||
expect(store.getState().selectedText).toBe('');
|
||||
});
|
||||
|
||||
it('useExpandedMediaStore persists fitExpandedImagesToScreen', async () => {
|
||||
const store = (await import('../use-expanded-media-store')).default;
|
||||
|
||||
expect(store.getState().fitExpandedImagesToScreen).toBe(false);
|
||||
|
||||
store.getState().setFitExpandedImagesToScreen(true);
|
||||
|
||||
expect(store.getState().fitExpandedImagesToScreen).toBe(true);
|
||||
expect(localStorage.getItem('expanded-media-store')).toContain('fitExpandedImagesToScreen');
|
||||
});
|
||||
|
||||
it('useSubplebbitOfflineStore merges updates and clears initialLoad after the timeout', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const store = (await import('../use-subplebbit-offline-store')).default;
|
||||
|
||||
store.getState().initializesubplebbitOfflineState('music-posting.eth');
|
||||
expect(store.getState().subplebbitOfflineState['music-posting.eth']).toEqual({ initialLoad: true });
|
||||
|
||||
store.getState().setSubplebbitOfflineState('music-posting.eth', {
|
||||
state: 'offline',
|
||||
updatedAt: 123,
|
||||
updatingState: 'recovering',
|
||||
});
|
||||
|
||||
expect(store.getState().subplebbitOfflineState['music-posting.eth']).toEqual({
|
||||
initialLoad: true,
|
||||
state: 'offline',
|
||||
updatedAt: 123,
|
||||
updatingState: 'recovering',
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(30_000);
|
||||
|
||||
expect(store.getState().subplebbitOfflineState['music-posting.eth']).toEqual({
|
||||
initialLoad: false,
|
||||
state: 'offline',
|
||||
updatedAt: 123,
|
||||
updatingState: 'recovering',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import * as React from 'react';
|
||||
import { createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||
|
||||
let latestValue: number[] = [];
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
const flushEffects = async (count = 3) => {
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
describe('useSubplebbitsLoadingStartTimestamps', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
|
||||
latestValue = [];
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('stores first-seen timestamps per board and only adds new addresses on rerender', async () => {
|
||||
const useSubplebbitsLoadingStartTimestamps = (await import('../use-subplebbits-loading-start-timestamps-store')).default;
|
||||
|
||||
const HookHarness = ({ addresses }: { addresses?: string[] }) => {
|
||||
const value = useSubplebbitsLoadingStartTimestamps(addresses);
|
||||
React.useLayoutEffect(() => {
|
||||
latestValue = value;
|
||||
}, [value]);
|
||||
return null;
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(createElement(HookHarness, { addresses: ['music.eth', 'tech.eth'] }));
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(latestValue).toEqual([1_704_067_200, 1_704_067_200]);
|
||||
|
||||
vi.setSystemTime(new Date('2024-01-01T00:10:00Z'));
|
||||
|
||||
await act(async () => {
|
||||
root.render(createElement(HookHarness, { addresses: ['music.eth', 'biz.eth'] }));
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(latestValue).toEqual([1_704_067_200, 1_704_067_800]);
|
||||
});
|
||||
});
|
||||
@@ -3,5 +3,24 @@ import { defineConfig } from 'vitest/config';
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
coverage: {
|
||||
include: ['src/**/*.{ts,tsx}', 'electron/**/*.{js,mjs}'],
|
||||
exclude: [
|
||||
'**/*.d.ts',
|
||||
'**/*.test.*',
|
||||
'**/__tests__/**',
|
||||
'src/**/index.ts',
|
||||
'src/**/index.tsx',
|
||||
'src/generated/**',
|
||||
'src/env.d.ts',
|
||||
'src/globals.d.ts',
|
||||
'src/modules.d.ts',
|
||||
'src/sw.ts',
|
||||
'src/lib/react-scan.ts',
|
||||
'electron/**/*.test.js',
|
||||
'electron/vite-config.js',
|
||||
'electron/vite.preload.config.js',
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user