diff --git a/mobile/app/(tabs)/communication.tsx b/mobile/app/(tabs)/communication.tsx index 8547710b..15aa34a8 100644 --- a/mobile/app/(tabs)/communication.tsx +++ b/mobile/app/(tabs)/communication.tsx @@ -92,6 +92,8 @@ function ChatScreen({ token }: { token: string }) { setBusy(room.key); const ready = await chatApi.provision(token, room); if (!alive.current || version !== selectionRevision.current || directoryRef.current?.hiddenRoomKeys.includes(room.key)) return; + // A list requested before provisioning must not replace the new room with its placeholder. + ++directoryRevision.current; setDirectory(previous => previous && ({ ...previous, rooms: previous.rooms.map(item => item.key === room.key ? ready : item) })); setActiveKey(ready.key); } catch (err) { if (alive.current && version === selectionRevision.current) setError(err instanceof Error ? err.message : 'Chat konnte nicht geöffnet werden.'); } diff --git a/mobile/tests/chat-directory.test.mjs b/mobile/tests/chat-directory.test.mjs new file mode 100644 index 00000000..71de678a --- /dev/null +++ b/mobile/tests/chat-directory.test.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { test } from 'node:test'; +import React from 'react'; +import { act, create } from 'react-test-renderer'; +import ts from 'typescript'; +import * as chatModel from '../src/lib/chat.ts'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +const require = createRequire(import.meta.url); +const deferred = () => { let resolve; const promise = new Promise(yes => { resolve = yes; }); return { promise, resolve }; }; + +test('Eine verspätete Raumliste verdrängt keinen gerade angelegten Projektchat', async () => { + const project = { key: 'project_42', name: 'Projekt', source: 'internal', type: 'project', exists: false, projectId: 42 }; + const directory = { rooms: [{ key: 'allgemein', name: 'Allgemein', source: 'internal', exists: true }, project], hiddenRoomKeys: [], invitations: [], projects: [] }; + const provision = deferred(), list = deferred(), timers = new Map(); + let directoryCalls = 0; + const api = { directory: () => ++directoryCalls === 1 ? Promise.resolve(directory) : list.promise, provision: () => provision.promise }; + const output = ts.transpileModule(readFileSync(new URL('../app/(tabs)/communication.tsx', import.meta.url), 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, target: ts.ScriptTarget.ES2022 }, + }).outputText; + const exports = {}; + new Function('require', 'exports', 'setInterval', 'clearInterval', output)(name => { + if (name === 'react-native') return { + ...Object.fromEntries(['ActivityIndicator', 'FlatList', 'KeyboardAvoidingView', 'Modal', 'Pressable', 'ScrollView', 'Text', 'TextInput', 'View'].map(key => [key, key])), + StyleSheet: { create: value => value }, Platform: { OS: 'ios' }, DeviceEventEmitter: { emit() {} }, + AppState: { currentState: 'active', addEventListener: () => ({ remove() {} }) }, + }; + if (name === '@expo/vector-icons') return { Ionicons: 'Icon' }; + if (name === '@react-navigation/native') return { useIsFocused: () => true }; + if (name === 'expo-crypto') return { randomUUID: () => 'draft-id' }; + if (name === '@/components/chat-conversation') return { ChatConversation: 'Conversation' }; + if (name === '@/src/lib/api') return {}; + if (name === '@/src/lib/chat-api') return { chatApi: api }; + if (name === '@/src/lib/chat') return chatModel; + if (name === '@/src/lib/server-config') return { getApiBaseUrlSync: () => 'https://test.invalid' }; + if (name === '@/src/providers/auth-provider') return { useAuth: () => ({ token: 'test', activeTenantId: 1 }) }; + return require(name); + }, exports, callback => { const id = Symbol(); timers.set(id, callback); return id; }, id => timers.delete(id)); + let root; + try { + await act(async () => { root = create(React.createElement(exports.default)); }); + const roomList = root.root.findAllByType('FlatList').find(node => node.props.horizontal); + await act(async () => roomList.props.renderItem({ item: project }).props.onPress()); + await act(async () => { for (const callback of timers.values()) callback(); }); + assert.equal(directoryCalls, 2); + await act(async () => provision.resolve({ ...project, exists: true })); + assert.equal(root.root.findByType('Conversation').props.room.key, 'project_42'); + await act(async () => list.resolve(directory)); + assert.equal(root.root.findByType('Conversation').props.room.key, 'project_42'); + } finally { await act(async () => root?.unmount()); } +});