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:
plebeius
2026-03-08 13:59:38 +08:00
parent 7da0df9466
commit 76eb4b537e
18 changed files with 1635 additions and 0 deletions
@@ -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');
});
});
+47
View File
@@ -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);
});
});
+67
View File
@@ -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('');
});
});
+196
View File
@@ -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``` &nbsp;')).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);
});
});
+62
View File
@@ -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);
});
});