test: expand interface settings and account editor coverage

Added coverage for version check button states, language selector, fit-expanded-images toggle, and account editor warning gate, continue flow, save/reset, and JSON validation.
This commit is contained in:
plebeius
2026-03-08 13:19:06 +08:00
parent e3907e7420
commit 7da0df9466
2 changed files with 333 additions and 33 deletions
@@ -3,21 +3,33 @@ import { createElement } from 'react';
import { createRoot, Root } from 'react-dom/client';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import packageJson from '../../../../../package.json';
import InterfaceSettings from '../interface-settings';
import useFeedViewSettingsStore from '../../../../stores/use-feed-view-settings-store';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
const testState = vi.hoisted(() => ({
alertMock: vi.fn(),
changeLanguageMock: vi.fn(),
fetchMock: vi.fn(),
fitExpandedImagesToScreen: false,
setFitExpandedImagesToScreenMock: vi.fn(),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { changeLanguage: vi.fn(), language: 'en' },
t: (key: string, opts?: Record<string, unknown>) => (opts ? `${key}:${JSON.stringify(opts)}` : key),
i18n: { changeLanguage: testState.changeLanguageMock, language: 'en' },
}),
}));
vi.mock('../../../../stores/use-expanded-media-store', () => ({
default: () => ({ fitExpandedImagesToScreen: false, setFitExpandedImagesToScreen: vi.fn() }),
default: () => ({
fitExpandedImagesToScreen: testState.fitExpandedImagesToScreen,
setFitExpandedImagesToScreen: testState.setFitExpandedImagesToScreenMock,
}),
}));
vi.mock('../../version', () => ({
@@ -35,6 +47,20 @@ const STORAGE_KEY = 'feed-view-settings-store';
let root: Root;
let container: HTMLDivElement;
const createFetchResponse = (body: unknown) => ({
json: vi.fn().mockResolvedValue(body),
});
const createDeferred = <T,>() => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((innerResolve, innerReject) => {
resolve = innerResolve;
reject = innerReject;
});
return { promise, resolve, reject };
};
const render = (children: React.ReactNode) => {
act(() => {
root.render(createElement(MemoryRouter, {}, children));
@@ -47,7 +73,14 @@ describe('InterfaceSettings', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.removeItem(STORAGE_KEY);
testState.alertMock.mockReset();
testState.changeLanguageMock.mockReset();
testState.fetchMock.mockReset();
testState.fitExpandedImagesToScreen = false;
testState.setFitExpandedImagesToScreenMock.mockReset();
useFeedViewSettingsStore.getState().setEnableInfiniteScroll(false);
vi.stubGlobal('alert', testState.alertMock);
vi.stubGlobal('fetch', testState.fetchMock);
setItemSpy = vi.spyOn(Storage.prototype, 'setItem');
container = document.createElement('div');
document.body.appendChild(container);
@@ -58,6 +91,7 @@ describe('InterfaceSettings', () => {
act(() => root.unmount());
container.remove();
setItemSpy.mockRestore();
vi.unstubAllGlobals();
});
it('renders enable_infinite_scroll_tip under the infinite scroll checkbox', () => {
@@ -92,4 +126,109 @@ describe('InterfaceSettings', () => {
expect(container.querySelector('[data-testid="board-mode"]')?.textContent).toBe('infinite');
expect(setItemSpy).toHaveBeenCalledWith(STORAGE_KEY, expect.stringContaining('"enableInfiniteScroll":true'));
});
it('toggles fit expanded images through the media store', async () => {
render(createElement(InterfaceSettings));
const label = Array.from(container.querySelectorAll('label')).find((candidate) => candidate.textContent?.toLowerCase().includes('fit_expanded_images_to_screen'));
const checkbox = label?.querySelector<HTMLInputElement>('input[type="checkbox"]');
expect(checkbox).toBeTruthy();
await act(async () => {
checkbox?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(testState.setFitExpandedImagesToScreenMock).toHaveBeenCalledWith(true);
});
it('changes the interface language from the language selector', async () => {
render(createElement(InterfaceSettings));
const select = container.querySelector<HTMLSelectElement>('select');
expect(select).toBeTruthy();
await act(async () => {
if (select) {
select.value = 'fr';
select.dispatchEvent(new Event('change', { bubbles: true }));
}
});
expect(testState.changeLanguageMock).toHaveBeenCalledWith('fr');
});
it('disables the update button while fetching and restores it afterward', async () => {
const pendingFetch = createDeferred<ReturnType<typeof createFetchResponse>>();
testState.fetchMock.mockReturnValueOnce(pendingFetch.promise);
render(createElement(InterfaceSettings));
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'check');
expect(button).toBeTruthy();
await act(async () => {
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(button?.disabled).toBe(true);
pendingFetch.resolve(createFetchResponse({ version: packageJson.version }));
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(button?.disabled).toBe(false);
expect(testState.alertMock).toHaveBeenCalledWith(expect.stringContaining('latest_stable_version'));
});
it('alerts when a newer stable version is available', async () => {
testState.fetchMock.mockResolvedValueOnce(createFetchResponse({ version: '9.9.9' }));
render(createElement(InterfaceSettings));
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'check');
expect(button).toBeTruthy();
await act(async () => {
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
const message = String(testState.alertMock.mock.calls.at(-1)?.[0] ?? '');
expect(message).toContain('new_stable_version');
expect(message).toContain('refresh_to_update');
});
it('alerts when already on the latest stable version', async () => {
testState.fetchMock.mockResolvedValueOnce(createFetchResponse({ version: packageJson.version }));
render(createElement(InterfaceSettings));
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'check');
expect(button).toBeTruthy();
await act(async () => {
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(testState.alertMock).toHaveBeenCalledWith(expect.stringContaining('latest_stable_version'));
});
it('alerts when fetching the latest version info fails', async () => {
testState.fetchMock.mockRejectedValueOnce(new Error('network down'));
render(createElement(InterfaceSettings));
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'check');
expect(button).toBeTruthy();
await act(async () => {
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(testState.alertMock).toHaveBeenCalledWith('Failed to fetch latest version info: Error: network down');
});
});
@@ -1,13 +1,35 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, Root } from 'react-dom/client';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import AccountDataEditor from '../account-data-editor';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
const DEFAULT_JSON = '{"account":{"name":"test"}}';
const testState = vi.hoisted(() => ({
account: { id: 'test-id', name: 'Account 1', author: { address: '0x123', shortAddress: '0x1...3' } },
alertMock: vi.fn(),
buildEditableAccountJsonMock: vi.fn<(account: unknown) => string>(() => DEFAULT_JSON),
buildSavePayloadMock: vi.fn<(parsed: { account: Record<string, unknown> }, id: string) => Record<string, unknown>>((parsed, id) => ({
...parsed.account,
id,
})),
locationState: null as { state?: { returnTo?: string } } | null,
navigateMock: vi.fn(),
safeParseAccountJsonMock: vi.fn<(text: string) => { account: Record<string, unknown> } | null>((text: string) => {
try {
const parsed = JSON.parse(text);
return parsed?.account ? parsed : null;
} catch {
return null;
}
}),
setAccountMock: vi.fn(),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
@@ -15,36 +37,116 @@ vi.mock('react-i18next', () => ({
}),
}));
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return {
...actual,
useLocation: () => testState.locationState ?? { state: null },
useNavigate: () => testState.navigateMock,
};
});
vi.mock('../../../lib/utils/account-editor-utils', () => ({
buildEditableAccountJson: () => '{"account": {"name": "test"}}',
safeParseAccountJson: vi.fn((text: string) => {
try {
const p = JSON.parse(text);
return p?.account ? p : null;
} catch {
return null;
}
}),
buildSavePayload: vi.fn((parsed: { account: Record<string, unknown> }, id: string) => ({ ...parsed.account, id })),
buildEditableAccountJson: (account: unknown) => testState.buildEditableAccountJsonMock(account),
buildSavePayload: (parsed: { account: Record<string, unknown> }, id: string) => testState.buildSavePayloadMock(parsed, id),
safeParseAccountJson: (text: string) => testState.safeParseAccountJsonMock(text),
}));
vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({
useAccount: () => ({ id: 'test-id', name: 'Account 1', author: { address: '0x123', shortAddress: '0x1...3' } }),
setAccount: vi.fn(),
setAccount: (payload: Record<string, unknown>) => testState.setAccountMock(payload),
useAccount: () => testState.account,
}));
vi.mock('react-ace', async () => {
const ReactModule = await vi.importActual<typeof import('react')>('react');
return {
default: ({ value, onChange }: { value: string; onChange: (nextValue: string) => void }) =>
ReactModule.createElement('textarea', {
'data-testid': 'ace-editor',
onChange: (event: Event) => onChange((event.target as HTMLTextAreaElement).value),
value,
}),
};
});
vi.mock('ace-builds/src-noconflict/mode-json', () => ({}));
vi.mock('ace-builds/src-noconflict/theme-monokai', () => ({}));
let root: Root;
let container: HTMLDivElement;
const render = (children: React.ReactNode) => {
const flushEffects = async (count = 10) => {
for (let i = 0; i < count; i += 1) {
await act(async () => {
await Promise.resolve();
});
}
};
const waitForEditor = async () => {
for (let i = 0; i < 50; i += 1) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
await flushEffects(2);
if (container.querySelector('[data-testid="ace-editor"]')) {
return;
}
}
expect(container.querySelector('[data-testid="ace-editor"]')).toBeTruthy();
};
const clickButton = async (label: string) => {
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === label);
expect(button).toBeTruthy();
await act(async () => {
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
};
const changeEditorValue = async (value: string) => {
const editor = container.querySelector<HTMLTextAreaElement>('[data-testid="ace-editor"]');
expect(editor).toBeTruthy();
await act(async () => {
if (editor) {
const descriptor = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value');
descriptor?.set?.call(editor, value);
editor.dispatchEvent(new Event('input', { bubbles: true }));
editor.dispatchEvent(new Event('change', { bubbles: true }));
}
});
};
const renderEditor = () => {
act(() => {
root.render(createElement(MemoryRouter, { initialEntries: ['/settings/account-data'] }, children));
root.render(createElement(AccountDataEditor));
});
};
describe('AccountDataEditor', () => {
beforeEach(() => {
vi.clearAllMocks();
testState.account = { id: 'test-id', name: 'Account 1', author: { address: '0x123', shortAddress: '0x1...3' } };
testState.alertMock.mockReset();
testState.buildEditableAccountJsonMock.mockReturnValue(DEFAULT_JSON);
testState.buildSavePayloadMock.mockImplementation((parsed: { account: Record<string, unknown> }, id: string) => ({ ...parsed.account, id }));
testState.locationState = null;
testState.navigateMock.mockReset();
testState.safeParseAccountJsonMock.mockImplementation((text: string) => {
try {
const parsed = JSON.parse(text);
return parsed?.account ? parsed : null;
} catch {
return null;
}
});
testState.setAccountMock.mockReset();
vi.stubGlobal('alert', testState.alertMock);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
@@ -53,27 +155,86 @@ describe('AccountDataEditor', () => {
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
});
it('renders warning gate initially', () => {
render(createElement(AccountDataEditor));
it('navigates back to the default settings route from the warning gate', async () => {
renderEditor();
expect(container.textContent).toContain('private_key_warning_title');
await clickButton('go_back');
expect(testState.navigateMock).toHaveBeenCalledWith('/subs/settings#account-settings');
});
it('shows go_back and continue buttons in warning gate', () => {
render(createElement(AccountDataEditor));
const buttons = Array.from(container.querySelectorAll('button'));
const texts = buttons.map((b) => b.textContent ?? '');
expect(texts.some((t) => t.includes('go_back'))).toBe(true);
expect(texts.some((t) => t.includes('continue'))).toBe(true);
it('loads the editor after continue and respects custom return routes', async () => {
testState.locationState = { state: { returnTo: '/custom/settings#account' } };
renderEditor();
await clickButton('continue');
expect(container.textContent).toContain('loading_editor');
await waitForEditor();
expect(container.textContent).not.toContain('loading_editor');
expect(container.querySelector('[data-testid="ace-editor"]')).toBeTruthy();
await clickButton('return_to_settings');
expect(testState.navigateMock).toHaveBeenLastCalledWith('/custom/settings#account');
});
it('does not show editor controls in warning phase', () => {
render(createElement(AccountDataEditor));
const buttons = Array.from(container.querySelectorAll('button'));
const texts = buttons.map((b) => b.textContent ?? '');
expect(texts.some((t) => t.includes('save'))).toBe(false);
expect(texts.some((t) => t.includes('reset_changes'))).toBe(false);
expect(texts.some((t) => t.includes('return_to_settings'))).toBe(false);
it('resets edited text back to the account JSON snapshot', async () => {
renderEditor();
await clickButton('continue');
await waitForEditor();
expect(container.querySelector<HTMLTextAreaElement>('[data-testid="ace-editor"]')?.value).toBe(DEFAULT_JSON);
await changeEditorValue('{"account":{"name":"changed"}}');
expect(container.querySelector<HTMLTextAreaElement>('[data-testid="ace-editor"]')?.value).toBe('{"account":{"name":"changed"}}');
await clickButton('reset_changes');
expect(container.querySelector<HTMLTextAreaElement>('[data-testid="ace-editor"]')?.value).toBe(DEFAULT_JSON);
});
it('alerts on invalid JSON without attempting to save', async () => {
renderEditor();
await clickButton('continue');
await waitForEditor();
await changeEditorValue('not valid json');
await clickButton('save_changes');
expect(testState.alertMock).toHaveBeenCalledWith('Invalid JSON');
expect(testState.setAccountMock).not.toHaveBeenCalled();
});
it('saves valid JSON and navigates back to settings', async () => {
testState.setAccountMock.mockResolvedValueOnce(undefined);
renderEditor();
await clickButton('continue');
await waitForEditor();
await changeEditorValue('{"account":{"name":"changed"}}');
await clickButton('save_changes');
await flushEffects();
expect(testState.buildSavePayloadMock).toHaveBeenCalledWith({ account: { name: 'changed' } }, 'test-id');
expect(testState.setAccountMock).toHaveBeenCalledWith({ id: 'test-id', name: 'changed' });
expect(testState.navigateMock).toHaveBeenCalledWith('/subs/settings#account-settings');
});
it('surfaces save errors from setAccount', async () => {
testState.setAccountMock.mockRejectedValueOnce(new Error('save failed'));
renderEditor();
await clickButton('continue');
await waitForEditor();
await changeEditorValue('{"account":{"name":"changed"}}');
await clickButton('save_changes');
await flushEffects();
expect(testState.alertMock).toHaveBeenCalledWith('save failed');
});
});