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, { getCidForPostNumber, getScopedNumberToCidMap } 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'; import useThreadLiveUpdatesStore from '../use-thread-live-updates-store'; const resetReplyModalStore = () => { useReplyModalStore.setState({ showReplyModal: false, openEmpty: false, activeCid: null, parentNumber: null, threadNumber: null, threadCid: null, communityAddress: 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({ currentResetFunction: null, reset: null }); usePostNumberStore.setState({ numberToCid: {}, cidToNumber: {} }); useSelectedTextStore.getState().resetSelectedText(); useSortingStore.getState().setSortType('active'); useThreadLiveUpdatesStore.getState().resetState(); 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); const firstResetInvoker = useFeedResetStore.getState().reset; useFeedResetStore.getState().reset?.(); expect(resetMock).toHaveBeenCalledTimes(1); const nextResetMock = vi.fn(); useFeedResetStore.getState().setResetFunction(nextResetMock); expect(useFeedResetStore.getState().reset).toBe(firstResetInvoker); useFeedResetStore.getState().reset?.(); expect(resetMock).toHaveBeenCalledTimes(1); expect(nextResetMock).toHaveBeenCalledTimes(1); expect(useSortingStore.getState().sortType).toBe('active'); useSortingStore.getState().setSortType('replyCount'); expect(useSortingStore.getState().sortType).toBe('replyCount'); }); it('tracks thread live update toggle state and queues manual refresh requests', () => { const store = useThreadLiveUpdatesStore.getState(); expect(store.enabled).toBe(false); expect(store.isUpdating).toBe(false); expect(store.updateRequestId).toBe(0); expect(store.repliesResetRequestId).toBe(0); store.setEnabled(true); expect(useThreadLiveUpdatesStore.getState().enabled).toBe(true); store.toggleEnabled(); expect(useThreadLiveUpdatesStore.getState().enabled).toBe(false); store.requestUpdate(); expect(useThreadLiveUpdatesStore.getState().updateRequestId).toBe(1); store.startUpdate(); expect(useThreadLiveUpdatesStore.getState().isUpdating).toBe(true); store.finishUpdate(1); expect(useThreadLiveUpdatesStore.getState()).toMatchObject({ isUpdating: false, repliesResetRequestId: 1, }); store.requestUpdate(); expect(useThreadLiveUpdatesStore.getState().updateRequestId).toBe(2); store.startUpdate(); store.finishUpdate(2, false); expect(useThreadLiveUpdatesStore.getState()).toMatchObject({ isUpdating: false, repliesResetRequestId: 1, }); store.requestUpdate(); store.startUpdate(); store.requestUpdate(); store.startUpdate(); store.finishUpdate(3); expect(useThreadLiveUpdatesStore.getState()).toMatchObject({ isUpdating: true, repliesResetRequestId: 3, updateRequestId: 4, }); store.finishUpdate(4); expect(useThreadLiveUpdatesStore.getState()).toMatchObject({ isUpdating: false, repliesResetRequestId: 4, updateRequestId: 4, }); }); 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, communityAddress: 'music.eth' }, { cid: 'cid-2', number: 2, communityAddress: 'music.eth' }, { cid: 'cid-1-tech', number: 1, communityAddress: 'tech.eth' }, { cid: '', number: 3, communityAddress: 'music.eth' }, { cid: 'cid-no-number', communityAddress: '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('merges alias-scoped post number maps when the exact board scope is only partially populated', () => { const numberToCid = { 'music.eth': { 1: 'op-cid', 2: 'reply-cid', }, 'music.bso': { 30: 'late-reply-cid', }, }; expect(getScopedNumberToCidMap(numberToCid, 'music.bso')).toEqual({ 1: 'op-cid', 2: 'reply-cid', 30: 'late-reply-cid', }); expect(getCidForPostNumber(numberToCid, 'music.bso', 1)).toBe('op-cid'); expect(getCidForPostNumber(numberToCid, 'music.bso', 30)).toBe('late-reply-cid'); }); 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', communityAddress: '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', communityAddress: '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, }); }); });