From 76eb4b537e33544127a2511cbc235530f9df2747 Mon Sep 17 00:00:00 2001 From: plebeius Date: Sun, 8 Mar 2026 13:59:38 +0800 Subject: [PATCH] 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. --- src/hooks/__tests__/browser-hooks.test.tsx | 85 ++++++ src/hooks/__tests__/selector-hooks.test.tsx | 184 +++++++++++++ .../use-author-address-click.test.ts | 41 +++ src/lib/__tests__/init-translations.test.ts | 59 ++++ src/lib/__tests__/snow.test.ts | 47 ++++ src/lib/__tests__/update-favicon.test.ts | 67 +++++ src/lib/utils/__tests__/blotter-utils.test.ts | 39 +++ src/lib/utils/__tests__/misc-utils.test.ts | 196 +++++++++++++ src/lib/utils/__tests__/url-utils.test.ts | 62 +++++ src/lib/utils/__tests__/view-utils.test.ts | 52 ++++ .../__tests__/interaction-stores.test.ts | 259 ++++++++++++++++++ .../__tests__/persisted-extra-stores.test.ts | 121 ++++++++ .../__tests__/preference-stores.test.ts | 92 +++++++ src/stores/__tests__/publish-stores.test.ts | 99 +++++++ src/stores/__tests__/theme-store.test.ts | 72 +++++ src/stores/__tests__/ui-state-stores.test.ts | 76 +++++ ...ts-loading-start-timestamps-store.test.tsx | 65 +++++ vitest.config.ts | 19 ++ 18 files changed, 1635 insertions(+) create mode 100644 src/hooks/__tests__/browser-hooks.test.tsx create mode 100644 src/hooks/__tests__/selector-hooks.test.tsx create mode 100644 src/hooks/__tests__/use-author-address-click.test.ts create mode 100644 src/lib/__tests__/init-translations.test.ts create mode 100644 src/lib/__tests__/snow.test.ts create mode 100644 src/lib/__tests__/update-favicon.test.ts create mode 100644 src/lib/utils/__tests__/blotter-utils.test.ts create mode 100644 src/lib/utils/__tests__/misc-utils.test.ts create mode 100644 src/lib/utils/__tests__/url-utils.test.ts create mode 100644 src/lib/utils/__tests__/view-utils.test.ts create mode 100644 src/stores/__tests__/interaction-stores.test.ts create mode 100644 src/stores/__tests__/persisted-extra-stores.test.ts create mode 100644 src/stores/__tests__/preference-stores.test.ts create mode 100644 src/stores/__tests__/publish-stores.test.ts create mode 100644 src/stores/__tests__/theme-store.test.ts create mode 100644 src/stores/__tests__/ui-state-stores.test.ts create mode 100644 src/stores/__tests__/use-subplebbits-loading-start-timestamps-store.test.tsx diff --git a/src/hooks/__tests__/browser-hooks.test.tsx b/src/hooks/__tests__/browser-hooks.test.tsx new file mode 100644 index 00000000..56588913 --- /dev/null +++ b/src/hooks/__tests__/browser-hooks.test.tsx @@ -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 | Promise }).act as (cb: () => void | Promise) => void | Promise; + +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); + }); +}); diff --git a/src/hooks/__tests__/selector-hooks.test.tsx b/src/hooks/__tests__/selector-hooks.test.tsx new file mode 100644 index 00000000..cd76008b --- /dev/null +++ b/src/hooks/__tests__/selector-hooks.test.tsx @@ -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 | Promise }).act as (cb: () => void | Promise) => void | Promise; + +const testState = vi.hoisted(() => ({ + account: undefined as unknown, + accountSubplebbits: {} as Record, + directories: [] as Array<{ address: string; nsfw?: boolean }>, + directoryLookup: {} as Record, + 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']); + }); +}); diff --git a/src/hooks/__tests__/use-author-address-click.test.ts b/src/hooks/__tests__/use-author-address-click.test.ts new file mode 100644 index 00000000..56d4fc53 --- /dev/null +++ b/src/hooks/__tests__/use-author-address-click.test.ts @@ -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 = ` + + + + + `; + + 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 = ` + + + `; + + 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); + }); +}); diff --git a/src/lib/__tests__/init-translations.test.ts b/src/lib/__tests__/init-translations.test.ts new file mode 100644 index 00000000..d55acdad --- /dev/null +++ b/src/lib/__tests__/init-translations.test.ts @@ -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'); + }); +}); diff --git a/src/lib/__tests__/snow.test.ts b/src/lib/__tests__/snow.test.ts new file mode 100644 index 00000000..1fa15916 --- /dev/null +++ b/src/lib/__tests__/snow.test.ts @@ -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); + }); +}); diff --git a/src/lib/__tests__/update-favicon.test.ts b/src/lib/__tests__/update-favicon.test.ts new file mode 100644 index 00000000..c5048d2a --- /dev/null +++ b/src/lib/__tests__/update-favicon.test.ts @@ -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); + }); +}); diff --git a/src/lib/utils/__tests__/blotter-utils.test.ts b/src/lib/utils/__tests__/blotter-utils.test.ts new file mode 100644 index 00000000..2968533f --- /dev/null +++ b/src/lib/utils/__tests__/blotter-utils.test.ts @@ -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(''); + }); +}); diff --git a/src/lib/utils/__tests__/misc-utils.test.ts b/src/lib/utils/__tests__/misc-utils.test.ts new file mode 100644 index 00000000..efd0022e --- /dev/null +++ b/src/lib/utils/__tests__/misc-utils.test.ts @@ -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; + let dateTimeFormatSpy: ReturnType; + + 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); + }); +}); diff --git a/src/lib/utils/__tests__/url-utils.test.ts b/src/lib/utils/__tests__/url-utils.test.ts new file mode 100644 index 00000000..449585c2 --- /dev/null +++ b/src/lib/utils/__tests__/url-utils.test.ts @@ -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; + 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); + }); +}); diff --git a/src/lib/utils/__tests__/view-utils.test.ts b/src/lib/utils/__tests__/view-utils.test.ts new file mode 100644 index 00000000..05d6da0a --- /dev/null +++ b/src/lib/utils/__tests__/view-utils.test.ts @@ -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); + }); +}); diff --git a/src/stores/__tests__/interaction-stores.test.ts b/src/stores/__tests__/interaction-stores.test.ts new file mode 100644 index 00000000..c38379cb --- /dev/null +++ b/src/stores/__tests__/interaction-stores.test.ts @@ -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; + + 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, + }); + }); +}); diff --git a/src/stores/__tests__/persisted-extra-stores.test.ts b/src/stores/__tests__/persisted-extra-stores.test.ts new file mode 100644 index 00000000..9060751b --- /dev/null +++ b/src/stores/__tests__/persisted-extra-stores.test.ts @@ -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); + }); +}); diff --git a/src/stores/__tests__/preference-stores.test.ts b/src/stores/__tests__/preference-stores.test.ts new file mode 100644 index 00000000..32f610c5 --- /dev/null +++ b/src/stores/__tests__/preference-stores.test.ts @@ -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); + }); +}); diff --git a/src/stores/__tests__/publish-stores.test.ts b/src/stores/__tests__/publish-stores.test.ts new file mode 100644 index 00000000..14149cd5 --- /dev/null +++ b/src/stores/__tests__/publish-stores.test.ts @@ -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['setPublishPostStore']>[0]; +type PublishReplyInput = Parameters['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; + + 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(); + }); +}); diff --git a/src/stores/__tests__/theme-store.test.ts b/src/stores/__tests__/theme-store.test.ts new file mode 100644 index 00000000..2728c711 --- /dev/null +++ b/src/stores/__tests__/theme-store.test.ts @@ -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'); + }); +}); diff --git a/src/stores/__tests__/ui-state-stores.test.ts b/src/stores/__tests__/ui-state-stores.test.ts new file mode 100644 index 00000000..65ac15d0 --- /dev/null +++ b/src/stores/__tests__/ui-state-stores.test.ts @@ -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', + }); + }); +}); diff --git a/src/stores/__tests__/use-subplebbits-loading-start-timestamps-store.test.tsx b/src/stores/__tests__/use-subplebbits-loading-start-timestamps-store.test.tsx new file mode 100644 index 00000000..71a64b76 --- /dev/null +++ b/src/stores/__tests__/use-subplebbits-loading-start-timestamps-store.test.tsx @@ -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 | Promise }).act as (cb: () => void | Promise) => void | Promise; + +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]); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 647a9e54..d702575f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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', + ], + }, }, });