test: set up jest-expo and cover the Zustand store
This commit is contained in:
1 parent
1e0490b1f7
commit
37dfa265f5
5 files changed
+2830
-29
No files matched your search
@@ -0,0 +1,185 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
preset: 'jest-expo',
|
||||
setupFiles: ['<rootDir>/jest.setup.js'],
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/$1',
|
||||
},
|
||||
testMatch: ['<rootDir>/__tests__/**/*.test.{ts,tsx}'],
|
||||
testPathIgnorePatterns: ['/node_modules/', '/dist/', '/.agent/'],
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
jest.mock('@react-native-async-storage/async-storage', () =>
|
||||
require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
|
||||
);
|
||||
Generated
+2624
-28
File diff suppressed because it is too large.
Load diff
+9
-1
@@ -8,7 +8,10 @@
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "expo lint"
|
||||
"lint": "expo lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "jest",
|
||||
"build": "expo export --platform web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/plugin-transform-runtime": "^7.29.0",
|
||||
@@ -49,9 +52,14 @@
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react-native": "^13.3.3",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/react": "~19.1.0",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-expo": "~54.0.0",
|
||||
"react-test-renderer": "^19.1.0",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
|
||||
Reference in new issue
Block a user