refactor(settings): split SettingsScreen into section components and pure helpers

SettingsScreen CC 23 -> below 10. Form parsing, change detection and the dark-theme check moved to utils/ with unit tests; the dark switch now reads the OS scheme instead of comparing the background colour. Adds characterization tests for the screen.
This commit is contained in:
evandro committed 2026-10-10 04:18:40 -03:00
1 parent cb6eda0562
commit 434cba2a69
10 files changed
+671 -338

No files matched your search

+172
View File
@@ -0,0 +1,172 @@
import { act, fireEvent, render, screen } from '@testing-library/react-native';
import React from 'react';
import SettingsScreen from '../app/settings/index';
import * as api from '../services/lmStudioApi';
import { DEFAULT_SERVER_CONFIG } from '../services/types';
import { useAppStore } from '../store/useAppStore';
jest.mock('../services/lmStudioApi');
let mockSystemScheme: 'light' | 'dark' = 'light';
jest.mock('../hooks/useColorScheme', () => ({
useColorScheme: () => mockSystemScheme,
}));
const mockApi = api as jest.Mocked<typeof api>;
const state = () => useAppStore.getState();
const hostInput = () => screen.getByPlaceholderText('localhost');
const portInput = () => screen.getByPlaceholderText('1234');
const keyInput = () => screen.getByPlaceholderText('Leave empty for local');
beforeEach(() => {
jest.resetAllMocks();
mockSystemScheme = 'light';
useAppStore.setState(useAppStore.getInitialState(), true);
mockApi.getModels.mockResolvedValue([]);
});
describe('SettingsScreen connection form', () => {
it('is filled from the stored configuration and hides "Save Changes" until something changes', () => {
useAppStore.setState({ serverConfig: { host: '192.168.0.5', port: 4321, apiKey: 'k' } });
render(<SettingsScreen />);
expect(hostInput().props.value).toBe('192.168.0.5');
expect(portInput().props.value).toBe('4321');
expect(keyInput().props.value).toBe('k');
expect(screen.queryByText('Save Changes')).toBeNull();
});
it('shows "Save Changes" for each changed field', () => {
render(<SettingsScreen />);
fireEvent.changeText(hostInput(), 'other');
expect(screen.getByText('Save Changes')).toBeTruthy();
fireEvent.changeText(hostInput(), DEFAULT_SERVER_CONFIG.host);
expect(screen.queryByText('Save Changes')).toBeNull();
fireEvent.changeText(portInput(), '999');
expect(screen.getByText('Save Changes')).toBeTruthy();
fireEvent.changeText(portInput(), String(DEFAULT_SERVER_CONFIG.port));
expect(screen.queryByText('Save Changes')).toBeNull();
fireEvent.changeText(keyInput(), 'secret');
expect(screen.getByText('Save Changes')).toBeTruthy();
});
it('saves trimmed values', () => {
render(<SettingsScreen />);
fireEvent.changeText(hostInput(), ' 192.168.0.9 ');
fireEvent.changeText(portInput(), '8080');
fireEvent.changeText(keyInput(), ' key ');
fireEvent.press(screen.getByText('Save Changes'));
expect(state().serverConfig).toEqual({ host: '192.168.0.9', port: 8080, apiKey: 'key' });
});
it('falls back to localhost:1234 and no API key when the fields are blank or invalid', () => {
useAppStore.setState({ serverConfig: { host: 'lan', port: 4321, apiKey: 'k' } });
render(<SettingsScreen />);
fireEvent.changeText(hostInput(), ' ');
fireEvent.changeText(portInput(), 'abc');
fireEvent.changeText(keyInput(), ' ');
fireEvent.press(screen.getByText('Save Changes'));
expect(state().serverConfig).toEqual({ host: 'localhost', port: 1234, apiKey: undefined });
});
});
describe('SettingsScreen connection test', () => {
it('saves the configuration, reports success and loads the models', async () => {
mockApi.testConnection.mockResolvedValue(true);
render(<SettingsScreen />);
fireEvent.changeText(hostInput(), '10.0.0.2');
await act(async () => {
fireEvent.press(screen.getByText('Test Connection'));
});
expect(mockApi.testConnection).toHaveBeenCalledWith({ host: '10.0.0.2', port: 1234, apiKey: undefined });
expect(state().serverConfig.host).toBe('10.0.0.2');
expect(state().isConnected).toBe(true);
expect(mockApi.getModels).toHaveBeenCalledTimes(1);
expect(screen.getByText('Connection successful. API is ready.')).toBeTruthy();
expect(screen.getByText('Connected')).toBeTruthy();
});
it('reports failure and does not load models', async () => {
mockApi.testConnection.mockResolvedValue(false);
render(<SettingsScreen />);
await act(async () => {
fireEvent.press(screen.getByText('Test Connection'));
});
expect(state().isConnected).toBe(false);
expect(mockApi.getModels).not.toHaveBeenCalled();
expect(screen.getByText('Connection failed. Verify host and port.')).toBeTruthy();
expect(screen.getByText('Disconnected')).toBeTruthy();
});
it('shows a spinner and ignores presses while the test is running', async () => {
let resolveTest: (ok: boolean) => void = () => {};
mockApi.testConnection.mockReturnValue(new Promise((resolve) => (resolveTest = resolve)));
render(<SettingsScreen />);
await act(async () => {
fireEvent.press(screen.getByText('Test Connection'));
});
expect(screen.queryByText('Test Connection')).toBeNull();
await act(async () => {
resolveTest(false);
});
expect(screen.getByText('Test Connection')).toBeTruthy();
});
it('clears a previous result when the configuration is saved again', async () => {
mockApi.testConnection.mockResolvedValue(false);
render(<SettingsScreen />);
await act(async () => {
fireEvent.press(screen.getByText('Test Connection'));
});
expect(screen.getByText(/Connection failed/)).toBeTruthy();
fireEvent.changeText(hostInput(), 'changed');
fireEvent.press(screen.getByText('Save Changes'));
expect(screen.queryByText(/Connection failed/)).toBeNull();
});
});
describe('SettingsScreen dark mode switch', () => {
const darkSwitch = () => screen.UNSAFE_getByType(require('react-native').Switch);
it('is on for the dark preference and switches to light', () => {
useAppStore.setState({ theme: 'dark' });
render(<SettingsScreen />);
expect(darkSwitch().props.value).toBe(true);
fireEvent(darkSwitch(), 'valueChange', false);
expect(state().theme).toBe('light');
});
it('is off for the light preference and switches to dark', () => {
useAppStore.setState({ theme: 'light' });
render(<SettingsScreen />);
expect(darkSwitch().props.value).toBe(false);
fireEvent(darkSwitch(), 'valueChange', true);
expect(state().theme).toBe('dark');
});
it('follows the system scheme when the preference is "system"', () => {
mockSystemScheme = 'dark';
const first = render(<SettingsScreen />);
expect(darkSwitch().props.value).toBe(true);
first.unmount();
mockSystemScheme = 'light';
render(<SettingsScreen />);
expect(darkSwitch().props.value).toBe(false);
});
});
+77
View File
@@ -0,0 +1,77 @@
import { ChatMessage } from '../store/useAppStore';
import { findLastAssistantId, shouldShowTypingIndicator } from '../utils/chatSelectors';
import { buildServerConfig, isConfigChanged } from '../utils/serverConfig';
import { isDarkTheme } from '../utils/theme';
const msg = (id: string, role: ChatMessage['role'], content = 'x'): ChatMessage => ({
id,
role,
content,
timestamp: 0,
});
describe('findLastAssistantId', () => {
it('returns the most recent assistant message', () => {
expect(findLastAssistantId([msg('1', 'assistant'), msg('2', 'user'), msg('3', 'assistant')])).toBe('3');
});
it('returns null when there is no assistant message', () => {
expect(findLastAssistantId([msg('1', 'user')])).toBeNull();
expect(findLastAssistantId([])).toBeNull();
});
});
describe('shouldShowTypingIndicator', () => {
it('is true only while streaming an assistant message with no text yet', () => {
expect(shouldShowTypingIndicator([msg('1', 'user'), msg('2', 'assistant', '')], true)).toBe(true);
});
it('is false when not streaming, when text arrived, when the last message is the user, or when empty', () => {
expect(shouldShowTypingIndicator([msg('2', 'assistant', '')], false)).toBe(false);
expect(shouldShowTypingIndicator([msg('2', 'assistant', 'hi')], true)).toBe(false);
expect(shouldShowTypingIndicator([msg('1', 'user', '')], true)).toBe(false);
expect(shouldShowTypingIndicator([], true)).toBe(false);
});
});
describe('buildServerConfig', () => {
it('trims the host and API key and parses the port', () => {
expect(buildServerConfig({ host: ' 10.0.0.1 ', port: '8080', apiKey: ' k ' })).toEqual({
host: '10.0.0.1',
port: 8080,
apiKey: 'k',
});
});
it('uses the defaults for blank host, invalid port and blank key', () => {
expect(buildServerConfig({ host: '', port: 'abc', apiKey: ' ' })).toEqual({
host: 'localhost',
port: 1234,
apiKey: undefined,
});
});
});
describe('isConfigChanged', () => {
const saved = { host: 'lan', port: 1234, apiKey: 'k' };
it('is false when the form matches the saved config', () => {
expect(isConfigChanged({ host: 'lan', port: '1234', apiKey: 'k' }, saved)).toBe(false);
expect(isConfigChanged({ host: 'lan', port: '1234', apiKey: '' }, { host: 'lan', port: 1234 })).toBe(false);
});
it('is true when any single field differs', () => {
expect(isConfigChanged({ host: 'other', port: '1234', apiKey: 'k' }, saved)).toBe(true);
expect(isConfigChanged({ host: 'lan', port: '99', apiKey: 'k' }, saved)).toBe(true);
expect(isConfigChanged({ host: 'lan', port: '1234', apiKey: '' }, saved)).toBe(true);
});
});
describe('isDarkTheme', () => {
it('honours an explicit preference and falls back to the system scheme', () => {
expect(isDarkTheme('dark', 'light')).toBe(true);
expect(isDarkTheme('light', 'dark')).toBe(false);
expect(isDarkTheme('system', 'dark')).toBe(true);
expect(isDarkTheme('system', 'light')).toBe(false);
});
});