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,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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user