import AsyncStorage from '@react-native-async-storage/async-storage'; 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 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', editingMessageId: 'e', }); state().addMessage({ role: 'user', content: 'persist me' }); await new Promise((resolve) => setTimeout(resolve, 0)); const raw = await AsyncStorage.getItem('localaiapp-storage'); const persisted = JSON.parse(raw as string).state; expect(Object.keys(persisted).sort()).toEqual( ['chatSessions', 'currentChatId', 'messages', 'serverConfig', 'theme'], ); expect(persisted.serverConfig).toEqual({ host: 'lan.local', port: 4321, apiKey: 'k' }); expect(persisted.messages[0].content).toBe('persist me'); }); });