From 29b6c85541d34d7aafd2e99cab3c09c42282f472 Mon Sep 17 00:00:00 2001 From: plebeius Date: Sun, 8 Mar 2026 14:37:35 +0800 Subject: [PATCH] test: cover board actions, pagination, and thread routes Core browsing flows around board controls, pagination, and thread pages were still largely unmeasured in the honest whole-repo report. That left route mismatches, cached-thread hydration, board action wiring, and mod-queue control regressions underprotected in CI. --- .../__tests__/board-buttons.test.tsx | 381 ++++++++++++++++++ .../__tests__/board-pagination.test.tsx | 124 ++++++ src/views/post/__tests__/post.test.tsx | 309 ++++++++++++++ 3 files changed, 814 insertions(+) create mode 100644 src/components/board-buttons/__tests__/board-buttons.test.tsx create mode 100644 src/components/board-pagination/__tests__/board-pagination.test.tsx create mode 100644 src/views/post/__tests__/post.test.tsx diff --git a/src/components/board-buttons/__tests__/board-buttons.test.tsx b/src/components/board-buttons/__tests__/board-buttons.test.tsx new file mode 100644 index 00000000..d57aa6ca --- /dev/null +++ b/src/components/board-buttons/__tests__/board-buttons.test.tsx @@ -0,0 +1,381 @@ +import * as React from 'react'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DesktopBoardButtons, MobileBoardButtons } from '../board-buttons'; + +(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; + +type DirectoryEntry = { + address: string; + features?: { requirePostLinkIsMedia?: boolean }; + title?: string; +}; + +const testState = vi.hoisted(() => ({ + accountComment: undefined as { subplebbitAddress?: string } | undefined, + alertThresholdUnit: 'minutes' as 'hours' | 'minutes', + alertThresholdValue: 5, + commentsByCid: {} as Record, + directories: [ + { address: 'music-posting.eth', features: {}, title: '/mu/ - Music' }, + { address: 'tech-posting.eth', features: { requirePostLinkIsMedia: true }, title: '/g/ - Technology' }, + ] as DirectoryEntry[], + enableInfiniteScroll: false, + filter: 'all' as 'all' | 'nsfw' | 'sfw', + filteredCount: 0, + imageSize: 'Small' as 'Small' | 'Large', + isMobile: true, + linkCount: 3, + navigateMock: vi.fn(), + pageNumber: 7 as number | null, + resetMock: vi.fn(), + resolvedSubplebbitAddress: 'music-posting.eth' as string | undefined, + searchText: '', + setAlertThresholdMock: vi.fn(), + setFilterMock: vi.fn(), + setImageSizeMock: vi.fn(), + setShowOPCommentMock: vi.fn(), + setSortTypeMock: vi.fn(), + setViewModeMock: vi.fn(), + showOPComment: false, + sortType: 'active' as 'active' | 'new' | 'replyCount', + subscribeMock: vi.fn(), + subscribed: false, + unsubscribeMock: vi.fn(), + viewMode: 'compact' as 'compact' | 'feed', +})); + +function useCatalogFiltersStoreMock(selector?: (state: { filteredCount: number; searchText: string }) => T) { + const state = { + filteredCount: testState.filteredCount, + searchText: testState.searchText, + }; + return selector ? selector(state) : (state as T); +} + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => testState.navigateMock, + }; +}); + +vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({ + useAccountComment: () => testState.accountComment, + useComment: ({ commentCid }: { commentCid?: string }) => (commentCid ? testState.commentsByCid[commentCid] : undefined), + useSubscribe: () => ({ + subscribe: testState.subscribeMock, + subscribed: testState.subscribed, + unsubscribe: testState.unsubscribeMock, + }), +})); + +vi.mock('../../../hooks/use-post-page-number', () => ({ + usePostPageNumber: () => testState.pageNumber, +})); + +vi.mock('../../../hooks/use-directories', () => ({ + useDirectories: () => testState.directories, + useDirectoryByAddress: (address: string | undefined) => testState.directories.find((entry) => entry.address === address), +})); + +vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({ + useResolvedSubplebbitAddress: () => testState.resolvedSubplebbitAddress, +})); + +vi.mock('../../../stores/use-catalog-filters-store', () => ({ + default: useCatalogFiltersStoreMock, +})); + +vi.mock('../../../stores/use-catalog-style-store', () => ({ + default: () => ({ + imageSize: testState.imageSize, + setImageSize: testState.setImageSizeMock, + setShowOPComment: testState.setShowOPCommentMock, + showOPComment: testState.showOPComment, + }), +})); + +vi.mock('../../../stores/use-feed-reset-store', () => ({ + default: (selector: (state: { reset: typeof testState.resetMock }) => unknown) => + selector({ + reset: testState.resetMock, + }), +})); + +vi.mock('../../../stores/use-sorting-store', () => ({ + default: () => ({ + setSortType: testState.setSortTypeMock, + sortType: testState.sortType, + }), +})); + +vi.mock('../../../stores/use-all-feed-filter-store', () => ({ + default: () => ({ + filter: testState.filter, + setFilter: testState.setFilterMock, + }), +})); + +vi.mock('../../../stores/use-mod-queue-store', () => ({ + default: () => ({ + alertThresholdUnit: testState.alertThresholdUnit, + alertThresholdValue: testState.alertThresholdValue, + setAlertThreshold: testState.setAlertThresholdMock, + setViewMode: testState.setViewModeMock, + viewMode: testState.viewMode, + }), +})); + +vi.mock('../../../stores/use-feed-view-settings-store', () => ({ + default: (selector: (state: { enableInfiniteScroll: boolean }) => unknown) => + selector({ + enableInfiniteScroll: testState.enableInfiniteScroll, + }), +})); + +vi.mock('../../../hooks/use-count-links-in-replies', () => ({ + default: () => testState.linkCount, +})); + +vi.mock('../../../hooks/use-is-mobile', () => ({ + default: () => testState.isMobile, +})); + +vi.mock('../../catalog-filters', () => ({ + default: () => createElement('div', { 'data-testid': 'catalog-filters' }, 'catalog-filters'), +})); + +vi.mock('../../catalog-search', () => ({ + default: () => createElement('div', { 'data-testid': 'catalog-search' }, 'catalog-search'), +})); + +vi.mock('../../tooltip', () => ({ + default: ({ content, children }: { content: string; children: React.ReactNode }) => + createElement('span', { 'data-content': content, 'data-testid': 'tooltip' }, children), +})); + +vi.mock('../../../views/mod-queue/mod-queue', () => ({ + ModQueueButton: ({ boardIdentifier, isMobile }: { boardIdentifier?: string; isMobile?: boolean }) => + createElement('div', { 'data-mobile': String(!!isMobile), 'data-testid': 'mod-queue-button' }, boardIdentifier || 'global-mod-queue'), +})); + +let container: HTMLDivElement; +let root: Root; + +const renderWithRoute = async (element: React.ReactElement, initialEntry: string) => { + await act(async () => { + root.render( + createElement( + MemoryRouter, + { initialEntries: [initialEntry] }, + createElement( + Routes, + {}, + createElement(Route, { path: '/all/catalog', element }), + createElement(Route, { path: '/mod/queue', element }), + createElement(Route, { path: '/:boardIdentifier/catalog', element }), + createElement(Route, { path: '/:boardIdentifier/thread/:commentCid', element }), + createElement(Route, { path: '/:boardIdentifier', element }), + ), + ), + ); + }); +}; + +const clickButton = async (text: string) => { + const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === text); + await act(async () => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); +}; + +const changeSelect = async (select: HTMLSelectElement, value: string) => { + await act(async () => { + select.value = value; + select.dispatchEvent(new Event('change', { bubbles: true })); + }); +}; + +const setTrackedInputValue = (input: HTMLInputElement, value: string) => { + const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value'); + descriptor?.set?.call(input, value); +}; + +describe('BoardButtons', () => { + beforeEach(() => { + vi.clearAllMocks(); + testState.accountComment = undefined; + testState.alertThresholdUnit = 'minutes'; + testState.alertThresholdValue = 5; + testState.commentsByCid = {}; + testState.directories = [ + { address: 'music-posting.eth', features: {}, title: '/mu/ - Music' }, + { address: 'tech-posting.eth', features: { requirePostLinkIsMedia: true }, title: '/g/ - Technology' }, + ]; + testState.enableInfiniteScroll = false; + testState.filter = 'all'; + testState.filteredCount = 0; + testState.imageSize = 'Small'; + testState.isMobile = true; + testState.linkCount = 3; + testState.pageNumber = 7; + testState.resolvedSubplebbitAddress = 'music-posting.eth'; + testState.searchText = ''; + testState.showOPComment = false; + testState.sortType = 'active'; + testState.subscribed = false; + testState.viewMode = 'compact'; + Object.defineProperty(globalThis, 'alert', { + configurable: true, + value: vi.fn(), + writable: true, + }); + Object.defineProperty(window, 'scrollTo', { + configurable: true, + value: vi.fn(), + writable: true, + }); + Object.defineProperty(document.documentElement, 'scrollHeight', { + configurable: true, + value: 2400, + writable: true, + }); + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('renders desktop board actions for browsing boards, then searches OPs and triggers refresh, vote, subscribe, and archive flows', async () => { + await renderWithRoute(createElement(DesktopBoardButtons), '/mu'); + + expect(container.querySelector('[data-testid="mod-queue-button"]')?.textContent).toBe('mu'); + expect(container.textContent).toContain('subscribe'); + expect(container.textContent).toContain('vote'); + + const searchInput = container.querySelector('input[type="text"]'); + expect(searchInput).toBeTruthy(); + + await act(async () => { + if (searchInput) { + searchInput.value = 'cats'; + searchInput.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' })); + } + }); + + expect(testState.navigateMock).toHaveBeenCalledWith('/mu/catalog?q=cats'); + + await clickButton('refresh'); + await clickButton('subscribe'); + await clickButton('vote'); + await clickButton('archive'); + + expect(testState.resetMock).toHaveBeenCalledTimes(1); + expect(testState.subscribeMock).toHaveBeenCalledTimes(1); + expect(globalThis.alert).toHaveBeenNthCalledWith(1, 'vote_button_unavailable_intro\n\nvote_button_unavailable_outro'); + expect(globalThis.alert).toHaveBeenNthCalledWith(2, 'Work in progress'); + }); + + it('renders desktop catalog controls and wires sort, style, filter, and refresh updates', async () => { + testState.filteredCount = 4; + + await renderWithRoute(createElement(DesktopBoardButtons), '/all/catalog'); + + expect(container.textContent).toContain('filtered_threads'); + expect(container.textContent).toContain('4'); + expect(container.querySelector('[data-testid="catalog-filters"]')?.textContent).toBe('catalog-filters'); + expect(container.querySelector('[data-testid="catalog-search"]')?.textContent).toBe('catalog-search'); + + const selects = Array.from(container.querySelectorAll('select')); + expect(selects).toHaveLength(4); + + await changeSelect(selects[0]!, 'replyCount'); + await changeSelect(selects[1]!, 'Large'); + await changeSelect(selects[2]!, 'On'); + await changeSelect(selects[3]!, 'nsfw'); + await clickButton('refresh'); + + expect(testState.setSortTypeMock).toHaveBeenCalledWith('replyCount'); + expect(testState.setImageSizeMock).toHaveBeenCalledWith('Large'); + expect(testState.setShowOPCommentMock).toHaveBeenCalledWith(true); + expect(testState.setFilterMock).toHaveBeenCalledWith('nsfw'); + expect(testState.resetMock).toHaveBeenCalledTimes(1); + }); + + it('renders thread actions and post stats, then updates, auto-alerts, and scrolls to the bottom', async () => { + testState.commentsByCid = { + 'comment-1': { + cid: 'comment-1', + closed: true, + number: 99, + pinned: true, + postCid: 'comment-1', + replyCount: 9, + }, + }; + + await renderWithRoute(createElement(DesktopBoardButtons), '/mu/thread/comment-1'); + + const tooltips = Array.from(container.querySelectorAll('[data-testid="tooltip"]')); + expect(tooltips.map((tooltip) => tooltip.dataset.content)).toEqual(['Replies', 'Links', 'pagination.pageLabel']); + expect(tooltips.map((tooltip) => tooltip.textContent)).toEqual(['9', '3', '7']); + expect(container.textContent).toContain('Sticky /'); + expect(container.textContent).toContain('Closed /'); + + await clickButton('bottom'); + await clickButton('update'); + await clickButton('Auto'); + + expect(window.scrollTo).toHaveBeenCalledWith({ behavior: 'instant', top: 2400 }); + expect(testState.resetMock).toHaveBeenCalledTimes(1); + expect(globalThis.alert).toHaveBeenCalledWith('posts_auto_update_info'); + }); + + it('renders mobile mod-queue controls and clamps alert threshold updates', async () => { + testState.alertThresholdValue = 60; + + await renderWithRoute(createElement(MobileBoardButtons), '/mod/queue'); + + const returnLink = container.querySelector('a[href="/mod"]'); + expect(returnLink?.getAttribute('href')).toBe('/mod'); + + const thresholdInput = container.querySelector('input[type="number"]'); + const selects = Array.from(container.querySelectorAll('select')); + expect(thresholdInput).toBeTruthy(); + expect(selects).toHaveLength(2); + + await act(async () => { + if (thresholdInput) { + setTrackedInputValue(thresholdInput, '0'); + thresholdInput.dispatchEvent(new Event('input', { bubbles: true })); + thresholdInput.dispatchEvent(new Event('change', { bubbles: true })); + } + }); + expect(testState.setAlertThresholdMock).toHaveBeenCalledWith(1, 'minutes'); + + testState.setAlertThresholdMock.mockClear(); + await changeSelect(selects[0]!, 'hours'); + await changeSelect(selects[1]!, 'feed'); + await clickButton('refresh'); + + expect(testState.setAlertThresholdMock).toHaveBeenCalledWith(1, 'hours'); + expect(testState.setViewModeMock).toHaveBeenCalledWith('feed'); + expect(testState.resetMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/board-pagination/__tests__/board-pagination.test.tsx b/src/components/board-pagination/__tests__/board-pagination.test.tsx new file mode 100644 index 00000000..5a1e8e87 --- /dev/null +++ b/src/components/board-pagination/__tests__/board-pagination.test.tsx @@ -0,0 +1,124 @@ +import * as React from 'react'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import BoardPagination from '../board-pagination'; + +(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(() => ({ + enableInfiniteScroll: false, + navigateMock: vi.fn(), + setEnableInfiniteScrollMock: vi.fn(), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, values?: Record) => (values?.page ? `${key}:${values.page}` : key), + }), +})); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => testState.navigateMock, + }; +}); + +vi.mock('../../../stores/use-feed-view-settings-store', () => ({ + default: (selector: (state: { enableInfiniteScroll: boolean; setEnableInfiniteScroll: typeof testState.setEnableInfiniteScrollMock }) => unknown) => + selector({ + enableInfiniteScroll: testState.enableInfiniteScroll, + setEnableInfiniteScroll: testState.setEnableInfiniteScrollMock, + }), +})); + +vi.mock('../../style-selector/style-selector', () => ({ + default: () => createElement('div', { 'data-testid': 'style-selector' }, 'style-selector'), +})); + +let container: HTMLDivElement; +let root: Root; + +const renderPagination = (element: React.ReactElement) => { + act(() => { + root.render(createElement(MemoryRouter, {}, element)); + }); +}; + +describe('BoardPagination', () => { + beforeEach(() => { + vi.clearAllMocks(); + testState.enableInfiniteScroll = false; + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('renders standard pagination links and previous or next navigation controls', async () => { + renderPagination(createElement(BoardPagination, { basePath: '/mu', currentPage: 2, totalPages: 3 })); + + const pageLinks = Array.from(container.querySelectorAll('a')); + expect(pageLinks.map((link) => link.textContent)).toEqual(['1', '3']); + expect(pageLinks[0]?.getAttribute('href')).toBe('/mu'); + expect(pageLinks[1]?.getAttribute('href')).toBe('/mu/3'); + expect(container.querySelector('[aria-current="page"]')?.textContent).toBe('2'); + + await act(async () => { + const buttons = Array.from(container.querySelectorAll('button')); + buttons[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + buttons[1]?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(testState.navigateMock).toHaveBeenNthCalledWith(1, '/mu'); + expect(testState.navigateMock).toHaveBeenNthCalledWith(2, '/mu/3'); + }); + + it('shows the footer pagelist, catalog links, and enables infinite scroll from the all shortcut', async () => { + renderPagination(createElement(BoardPagination, { basePath: '/mu', currentPage: 1, footerStyle: true, totalPages: 3 })); + + expect(container.querySelector('[data-testid="style-selector"]')?.textContent).toBe('style-selector'); + expect(container.textContent).toContain('catalog'); + expect(container.textContent).toContain('archive'); + + const allLink = Array.from(container.querySelectorAll('[role="button"]')).find((element) => element.textContent === 'all'); + expect(allLink).toBeTruthy(); + + await act(async () => { + allLink?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' })); + }); + + expect(testState.setEnableInfiniteScrollMock).toHaveBeenCalledWith(true); + + await act(async () => { + const nextButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'next'); + nextButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(testState.navigateMock).toHaveBeenCalledWith('/mu/2'); + }); + + it('hides the footer pagelist for multiboards or when infinite scroll is already enabled', () => { + testState.enableInfiniteScroll = true; + renderPagination(createElement(BoardPagination, { basePath: '/all', currentPage: 1, footerStyle: true, isMultiboard: true, totalPages: 5 })); + + expect(container.querySelector('[data-testid="style-selector"]')).toBeTruthy(); + expect(container.textContent).not.toContain('catalog'); + expect(container.textContent).not.toContain('archive'); + }); + + it('returns nothing for single-page non-footer pagination', () => { + renderPagination(createElement(BoardPagination, { basePath: '/mu', currentPage: 1, totalPages: 1 })); + + expect(container.textContent).toBe(''); + }); +}); diff --git a/src/views/post/__tests__/post.test.tsx b/src/views/post/__tests__/post.test.tsx new file mode 100644 index 00000000..1c761c1c --- /dev/null +++ b/src/views/post/__tests__/post.test.tsx @@ -0,0 +1,309 @@ +import * as React from 'react'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import PostPage, { Post } from '../post'; + +(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; + +type TestComment = { + cid?: string; + content?: string; + error?: Error; + locked?: boolean; + number?: number; + parentCid?: string; + pinned?: boolean; + postCid?: string; + replyCount?: number; + replies?: unknown[]; + state?: string; + subplebbitAddress?: string; + timestamp?: number; + title?: string; +}; + +const testState = vi.hoisted(() => ({ + cachedComments: {} as Record, + commentsByCid: {} as Record, + directories: [{ address: 'music-posting.eth', title: '/mu/ - Music' }] as Array<{ address: string; title?: string }>, + editedCommentsByCid: {} as Record, + isMobile: false, + navigateMock: vi.fn(), + resolvedSubplebbitAddress: 'music-posting.eth' as string | undefined, + subplebbit: { + error: undefined as Error | undefined, + shortAddress: 'music-posting.eth', + title: '/mu/ - Music', + }, + subplebbitSnapshot: { + roles: { + '0xmod': { role: 'admin' }, + }, + } as { roles?: Record }, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => testState.navigateMock, + }; +}); + +vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({ + useComment: ({ commentCid }: { commentCid?: string }) => (commentCid ? testState.commentsByCid[commentCid] : undefined), + useEditedComment: ({ comment }: { comment?: TestComment }) => ({ + editedComment: comment?.cid ? testState.editedCommentsByCid[comment.cid] : undefined, + }), + useSubplebbit: () => testState.subplebbit, +})); + +vi.mock('@bitsocialhq/bitsocial-react-hooks/dist/stores/subplebbits-pages', () => ({ + default: (selector: (state: { comments: typeof testState.cachedComments }) => unknown) => + selector({ + comments: testState.cachedComments, + }), +})); + +vi.mock('../../../hooks/use-stable-subplebbit', () => ({ + useSubplebbitField: (_address: string | undefined, selector: (subplebbit: typeof testState.subplebbitSnapshot) => unknown) => selector(testState.subplebbitSnapshot), +})); + +vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({ + useResolvedSubplebbitAddress: () => testState.resolvedSubplebbitAddress, +})); + +vi.mock('../../../hooks/use-directories', async () => { + const actual = await vi.importActual('../../../hooks/use-directories'); + return { + ...actual, + useDirectories: () => testState.directories, + }; +}); + +vi.mock('../../../hooks/use-is-mobile', () => ({ + default: () => testState.isMobile, +})); + +vi.mock('../../../components/error-display/error-display', () => ({ + default: ({ error }: { error?: Error }) => createElement('div', { 'data-testid': 'error-display' }, error?.message || 'no-error'), +})); + +vi.mock('../../../components/footer', () => ({ + PageFooterDesktop: ({ firstRow, styleRow }: { firstRow: React.ReactNode; styleRow: React.ReactNode }) => + createElement('div', { 'data-testid': 'page-footer-desktop' }, firstRow, styleRow), + ThreadFooterFirstRow: ({ + isThreadClosed, + postCid, + subplebbitAddress, + threadNumber, + }: { + isThreadClosed: boolean; + postCid: string; + subplebbitAddress: string; + threadNumber?: number; + }) => createElement('div', { 'data-testid': 'thread-footer-first-row' }, `${postCid}:${threadNumber}:${subplebbitAddress}:${String(isThreadClosed)}`), + ThreadFooterMobile: ({ + isThreadClosed, + postCid, + subplebbitAddress, + threadNumber, + }: { + isThreadClosed: boolean; + postCid: string; + subplebbitAddress: string; + threadNumber?: number; + }) => createElement('div', { 'data-testid': 'thread-footer-mobile' }, `${postCid}:${threadNumber}:${subplebbitAddress}:${String(isThreadClosed)}`), + ThreadFooterStyleRow: () => createElement('div', { 'data-testid': 'thread-footer-style-row' }, 'thread-footer-style-row'), +})); + +vi.mock('../../../components/post-desktop', () => ({ + default: ({ post, roles, targetReplyCid }: { post?: TestComment; roles?: Record; targetReplyCid?: string }) => + createElement('div', { 'data-testid': 'post-desktop' }, `${post?.cid || 'missing'}:${targetReplyCid || 'none'}:${Object.keys(roles || {}).length}`), +})); + +vi.mock('../../../components/post-mobile', () => ({ + default: ({ post, roles, targetReplyCid }: { post?: TestComment; roles?: Record; targetReplyCid?: string }) => + createElement('div', { 'data-testid': 'post-mobile' }, `${post?.cid || 'missing'}:${targetReplyCid || 'none'}:${Object.keys(roles || {}).length}`), +})); + +let container: HTMLDivElement; +let root: Root; + +const flushEffects = async (count = 5) => { + for (let i = 0; i < count; i += 1) { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } +}; + +const renderPostPage = async (initialEntry: string) => { + await act(async () => { + root.render( + createElement( + MemoryRouter, + { initialEntries: [initialEntry] }, + createElement( + Routes, + {}, + createElement(Route, { path: '/all/thread/:commentCid', element: createElement(PostPage) }), + createElement(Route, { path: '/:boardIdentifier/thread/:commentCid', element: createElement(PostPage) }), + ), + ), + ); + }); + await flushEffects(); +}; + +describe('Post', () => { + beforeEach(() => { + vi.clearAllMocks(); + testState.cachedComments = {}; + testState.commentsByCid = {}; + testState.directories = [{ address: 'music-posting.eth', title: '/mu/ - Music' }]; + testState.editedCommentsByCid = {}; + testState.isMobile = false; + testState.resolvedSubplebbitAddress = 'music-posting.eth'; + testState.subplebbit = { + error: undefined, + shortAddress: 'music-posting.eth', + title: '/mu/ - Music', + }; + testState.subplebbitSnapshot = { + roles: { + '0xmod': { role: 'admin' }, + }, + }; + Object.defineProperty(window, 'scrollTo', { + configurable: true, + value: vi.fn(), + writable: true, + }); + document.title = 'before'; + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('renders edited posts through the desktop and mobile presenters with stable role data', async () => { + testState.editedCommentsByCid = { + 'post-1': { cid: 'edited-post', subplebbitAddress: 'music-posting.eth' }, + }; + + await act(async () => { + root.render(createElement(Post, { post: { cid: 'post-1', subplebbitAddress: 'music-posting.eth' } })); + }); + expect(container.querySelector('[data-testid="post-desktop"]')?.textContent).toBe('edited-post:none:1'); + + testState.isMobile = true; + await act(async () => { + root.render(createElement(Post, { post: { cid: 'post-2', subplebbitAddress: 'music-posting.eth' } })); + }); + expect(container.querySelector('[data-testid="post-mobile"]')?.textContent).toBe('post-2:none:1'); + }); + + it('hydrates thread pages from cached feed data, sets the document title, and renders thread footers', async () => { + testState.commentsByCid = { + 'cached-cid': { + cid: 'cached-cid', + state: 'updating', + subplebbitAddress: 'music-posting.eth', + }, + }; + testState.cachedComments = { + 'cached-cid': { + cid: 'cached-cid', + content: 'cached body', + number: 42, + replyCount: 0, + subplebbitAddress: 'music-posting.eth', + title: 'Cached thread', + }, + }; + + await renderPostPage('/mu/thread/cached-cid'); + + expect(container.querySelector('[data-testid="post-desktop"]')?.textContent).toBe('cached-cid:none:1'); + expect(container.querySelector('[data-testid="thread-footer-first-row"]')?.textContent).toBe('cached-cid:42:music-posting.eth:false'); + expect(container.querySelector('[data-testid="thread-footer-mobile"]')?.textContent).toBe('cached-cid:42:music-posting.eth:false'); + expect(document.title).toBe('/mu/ - Cached thread... - 5chan'); + expect(window.scrollTo).toHaveBeenCalledWith(0, 0); + }); + + it('redirects thread routes whose fetched comment belongs to a different board', async () => { + testState.commentsByCid = { + 'comment-1': { + cid: 'comment-1', + postCid: 'comment-1', + subplebbitAddress: 'other.eth', + title: 'Other board thread', + }, + }; + + await renderPostPage('/mu/thread/comment-1'); + + expect(testState.navigateMock).toHaveBeenCalledWith('/not-found', { replace: true }); + }); + + it('renders reply pages using the root post, highlights the reply target, and shows thread errors', async () => { + testState.commentsByCid = { + 'reply-cid': { + cid: 'reply-cid', + parentCid: 'root-cid', + postCid: 'root-cid', + subplebbitAddress: 'music-posting.eth', + }, + 'root-cid': { + cid: 'root-cid', + error: new Error('thread failed'), + locked: true, + number: 99, + replies: [], + replyCount: 4, + subplebbitAddress: 'music-posting.eth', + title: 'Root thread', + }, + }; + + await renderPostPage('/mu/thread/reply-cid'); + + expect(container.querySelector('[data-testid="post-desktop"]')?.textContent).toBe('root-cid:reply-cid:1'); + expect(container.querySelector('[data-testid="thread-footer-first-row"]')?.textContent).toBe('root-cid:99:music-posting.eth:true'); + expect(container.textContent).toContain('thread failed'); + }); + + it('shows missing-comment and board-load errors when no thread can be resolved', async () => { + testState.commentsByCid = { + 'missing-cid': { + error: new Error('missing comment'), + }, + }; + testState.subplebbit = { + error: new Error('board failed'), + shortAddress: 'music-posting.eth', + title: '/mu/ - Music', + }; + + await renderPostPage('/mu/thread/missing-cid'); + + expect(Array.from(container.querySelectorAll('[data-testid="error-display"]')).map((node) => node.textContent)).toEqual(['board failed', 'missing comment']); + expect(container.querySelector('[data-testid="thread-footer-first-row"]')).toBeNull(); + }); +});