The chat showed 'Server offline' until the user opened Settings, and the selected model was forgotten on every restart. useAutoConnect fetches the models once after the persisted settings are restored; activeModelId is now persisted.
189 lines
7.8 KiB
TypeScript
189 lines
7.8 KiB
TypeScript
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
import { appStorage } from '../store/debouncedStorage';
|
|
import { useAppStore } from '../store/useAppStore';
|
|
|
|
const state = () => useAppStore.getState();
|
|
|
|
beforeEach(() => {
|
|
useAppStore.setState(useAppStore.getInitialState(), true);
|
|
});
|
|
|
|
describe('messages', () => {
|
|
it('addMessage appends with a generated id and timestamp, and returns the id', () => {
|
|
const id = state().addMessage({ role: 'user', content: 'hi' });
|
|
const [msg] = state().messages;
|
|
|
|
expect(msg.id).toBe(id);
|
|
expect(msg).toMatchObject({ role: 'user', content: 'hi' });
|
|
expect(typeof msg.timestamp).toBe('number');
|
|
});
|
|
|
|
it('generates distinct ids for messages created in the same millisecond', () => {
|
|
const ids = new Set(Array.from({ length: 50 }, () => state().addMessage({ role: 'user', content: 'x' })));
|
|
expect(ids.size).toBe(50);
|
|
});
|
|
|
|
it('appendToMessage and updateMessageContent only touch the target message', () => {
|
|
const a = state().addMessage({ role: 'assistant', content: 'He' });
|
|
const b = state().addMessage({ role: 'assistant', content: 'other' });
|
|
|
|
state().appendToMessage(a, 'llo');
|
|
expect(state().messages.map((m) => m.content)).toEqual(['Hello', 'other']);
|
|
|
|
state().updateMessageContent(b, 'replaced');
|
|
expect(state().messages.map((m) => m.content)).toEqual(['Hello', 'replaced']);
|
|
});
|
|
|
|
it('editMessage replaces the content and marks it as edited', () => {
|
|
const id = state().addMessage({ role: 'user', content: 'old' });
|
|
state().editMessage(id, 'new');
|
|
expect(state().messages[0]).toMatchObject({ content: 'new', isEdited: true });
|
|
});
|
|
|
|
it('deleteMessage removes only the given message', () => {
|
|
const a = state().addMessage({ role: 'user', content: 'a' });
|
|
state().addMessage({ role: 'assistant', content: 'b' });
|
|
state().deleteMessage(a);
|
|
expect(state().messages.map((m) => m.content)).toEqual(['b']);
|
|
});
|
|
|
|
describe('deleteMessagesAfter', () => {
|
|
it('keeps the given message and drops everything after it', () => {
|
|
const a = state().addMessage({ role: 'user', content: 'a' });
|
|
state().addMessage({ role: 'assistant', content: 'b' });
|
|
state().addMessage({ role: 'user', content: 'c' });
|
|
state().deleteMessagesAfter(a);
|
|
expect(state().messages.map((m) => m.content)).toEqual(['a']);
|
|
});
|
|
|
|
it('does nothing when the id is unknown', () => {
|
|
state().addMessage({ role: 'user', content: 'a' });
|
|
state().deleteMessagesAfter('missing');
|
|
expect(state().messages).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
it('clearChat empties the conversation and forgets the current session', () => {
|
|
state().addMessage({ role: 'user', content: 'a' });
|
|
state().setCurrentChatId('abc');
|
|
state().clearChat();
|
|
expect(state().messages).toEqual([]);
|
|
expect(state().currentChatId).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('chat sessions', () => {
|
|
it('saveCurrentChat ignores an empty conversation', () => {
|
|
state().saveCurrentChat();
|
|
expect(state().chatSessions).toEqual([]);
|
|
expect(state().currentChatId).toBeNull();
|
|
});
|
|
|
|
it('creates a session titled by the first user message and makes it current', () => {
|
|
state().addMessage({ role: 'assistant', content: 'welcome' });
|
|
state().addMessage({ role: 'user', content: 'first question' });
|
|
state().saveCurrentChat();
|
|
|
|
expect(state().chatSessions).toHaveLength(1);
|
|
const [session] = state().chatSessions;
|
|
expect(session.title).toBe('first question');
|
|
expect(session.messages).toHaveLength(2);
|
|
expect(state().currentChatId).toBe(session.id);
|
|
});
|
|
|
|
it('truncates long titles to 40 characters plus an ellipsis', () => {
|
|
state().addMessage({ role: 'user', content: 'x'.repeat(60) });
|
|
state().saveCurrentChat();
|
|
expect(state().chatSessions[0].title).toBe(`${'x'.repeat(40)}...`);
|
|
});
|
|
|
|
it('falls back to "New Chat" when there is no user message', () => {
|
|
state().addMessage({ role: 'assistant', content: 'hello' });
|
|
state().saveCurrentChat();
|
|
expect(state().chatSessions[0].title).toBe('New Chat');
|
|
});
|
|
|
|
it('updates the existing session instead of creating another one', () => {
|
|
state().addMessage({ role: 'user', content: 'q' });
|
|
state().saveCurrentChat();
|
|
const firstId = state().currentChatId;
|
|
|
|
state().addMessage({ role: 'assistant', content: 'a' });
|
|
state().saveCurrentChat();
|
|
|
|
expect(state().chatSessions).toHaveLength(1);
|
|
expect(state().chatSessions[0].id).toBe(firstId);
|
|
expect(state().chatSessions[0].messages).toHaveLength(2);
|
|
});
|
|
|
|
it('only rewrites the session that matches currentChatId', () => {
|
|
useAppStore.setState({
|
|
chatSessions: [
|
|
{ id: '1', title: 'one', updatedAt: 1, messages: [] },
|
|
{ id: '2', title: 'two', updatedAt: 2, messages: [] },
|
|
],
|
|
currentChatId: '2',
|
|
messages: [{ id: 'm', role: 'user', content: 'changed', timestamp: 3 }],
|
|
});
|
|
state().saveCurrentChat();
|
|
expect(state().chatSessions[0]).toMatchObject({ id: '1', title: 'one' });
|
|
expect(state().chatSessions[1]).toMatchObject({ id: '2', title: 'changed' });
|
|
});
|
|
|
|
it('loadChat restores the messages of a session, and ignores unknown ids', () => {
|
|
const messages = [{ id: 'm', role: 'user' as const, content: 'saved', timestamp: 1 }];
|
|
useAppStore.setState({ chatSessions: [{ id: 's', title: 't', updatedAt: 1, messages }] });
|
|
|
|
state().loadChat('missing');
|
|
expect(state().messages).toEqual([]);
|
|
|
|
state().loadChat('s');
|
|
expect(state().messages).toEqual(messages);
|
|
expect(state().currentChatId).toBe('s');
|
|
});
|
|
|
|
it('deleteChatSession resets the open conversation only when it deletes the current one', () => {
|
|
const session = (id: string) => ({ id, title: id, updatedAt: 1, messages: [] });
|
|
useAppStore.setState({
|
|
chatSessions: [session('a'), session('b')],
|
|
currentChatId: 'a',
|
|
messages: [{ id: 'm', role: 'user', content: 'x', timestamp: 1 }],
|
|
});
|
|
|
|
state().deleteChatSession('b');
|
|
expect(state().chatSessions.map((s) => s.id)).toEqual(['a']);
|
|
expect(state().messages).toHaveLength(1);
|
|
|
|
state().deleteChatSession('a');
|
|
expect(state().chatSessions).toEqual([]);
|
|
expect(state().messages).toEqual([]);
|
|
expect(state().currentChatId).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('persistence', () => {
|
|
it('persists settings, the selected model and chats but not transient connection or streaming state', async () => {
|
|
useAppStore.setState({
|
|
theme: 'dark',
|
|
serverConfig: { host: 'lan.local', port: 4321, apiKey: 'k' },
|
|
isConnected: true,
|
|
isStreaming: true,
|
|
activeModelId: 'model',
|
|
availableModels: [{ id: 'model', object: 'model', owned_by: 'x' }],
|
|
editingMessageId: 'e',
|
|
});
|
|
state().addMessage({ role: 'user', content: 'persist me' });
|
|
|
|
await appStorage.flush();
|
|
const raw = await AsyncStorage.getItem('localaiapp-storage');
|
|
const persisted = JSON.parse(raw as string).state;
|
|
|
|
expect(Object.keys(persisted).sort()).toEqual(
|
|
['activeModelId', 'chatSessions', 'currentChatId', 'messages', 'serverConfig', 'theme'],
|
|
);
|
|
expect(persisted.serverConfig).toEqual({ host: 'lan.local', port: 4321, apiKey: 'k' });
|
|
expect(persisted.activeModelId).toBe('model');
|
|
expect(persisted.messages[0].content).toBe('persist me');
|
|
});
|
|
});
|