Buckets:
| import { beforeEach, describe, expect, it, vi } from 'vitest'; | |
| vi.mock('$lib/services/database.service', () => ({ | |
| DatabaseService: { getConversationsWithMessages: vi.fn() } | |
| })); | |
| import { MessageRole, MessageType } from '$lib/enums'; | |
| import { ConversationTransferService } from '$lib/services/conversation-transfer.service'; | |
| import { DatabaseService } from '$lib/services/database.service'; | |
| import { conversationsStore } from '$lib/stores/conversations/index.svelte'; | |
| import type { DatabaseConversation, DatabaseMessage } from '$lib/types/database'; | |
| import { filterByLeafNodeId } from '$lib/utils/branching'; | |
| /** | |
| * Reproduces the exported-conversation bug: | |
| * | |
| * A conversation created in the current page session keeps `currNode: ''` in the | |
| * sidebar list, because that list is only loaded at init while IndexedDB is stamped | |
| * on every message insert. | |
| * | |
| * Exporting from the cached record resulted in no branch pointer, and importing | |
| * the file showed every branch at once. | |
| */ | |
| const fetchMock = vi.mocked(DatabaseService.getConversationsWithMessages); | |
| beforeEach(() => { | |
| fetchMock.mockReset(); | |
| }); | |
| const CONV_ID = 'c1'; | |
| function message( | |
| id: string, | |
| parent: string | null, | |
| timestamp: number, | |
| role: MessageRole, | |
| type: MessageType = MessageType.TEXT | |
| ): DatabaseMessage { | |
| return { | |
| children: [], | |
| content: id, | |
| convId: CONV_ID, | |
| id, | |
| parent, | |
| role, | |
| timestamp, | |
| toolCalls: '', | |
| type | |
| } as DatabaseMessage; | |
| } | |
| /** root -> u1 -> a1 -> { u2a -> a2a (older) | u2b -> a2b (newer) } */ | |
| function branchedMessages(): DatabaseMessage[] { | |
| const messages = [ | |
| message('root', null, 10, MessageRole.USER, MessageType.ROOT), | |
| message('u1', 'root', 20, MessageRole.USER), | |
| message('a1', 'u1', 30, MessageRole.ASSISTANT), | |
| message('u2a', 'a1', 40, MessageRole.USER), | |
| message('a2a', 'u2a', 50, MessageRole.ASSISTANT), | |
| message('u2b', 'a1', 60, MessageRole.USER), | |
| message('a2b', 'u2b', 70, MessageRole.ASSISTANT) | |
| ]; | |
| for (const m of messages) { | |
| m.children = messages.filter((c) => c.parent === m.id).map((c) => c.id); | |
| } | |
| return messages; | |
| } | |
| /** A second conversation with a single linear path: root -> u1 -> a1. */ | |
| function linearMessages(convId: string): DatabaseMessage[] { | |
| return [ | |
| { ...message('root', null, 10, MessageRole.USER, MessageType.ROOT), children: ['u1'], convId }, | |
| { ...message('u1', 'root', 20, MessageRole.USER), children: ['a1'], convId }, | |
| { ...message('a1', 'u1', 30, MessageRole.ASSISTANT), convId } | |
| ]; | |
| } | |
| function conversation(currNode: string, id: string = CONV_ID): DatabaseConversation { | |
| return { currNode, id, lastModified: 100, name: `Chat ${id}` }; | |
| } | |
| /** Mirrors `conversationsStore.loadConversation` */ | |
| function displayedIds(imported: { conv: DatabaseConversation; messages: DatabaseMessage[] }) { | |
| if (imported.conv.currNode) { | |
| return filterByLeafNodeId(imported.messages, imported.conv.currNode, false).map((m) => m.id); | |
| } | |
| return imported.messages.map((m) => m.id); | |
| } | |
| /** Export then re-import */ | |
| function roundTrip(conv: DatabaseConversation) { | |
| const jsonl = ConversationTransferService.serializeSessionToJsonl({ | |
| conv, | |
| messages: branchedMessages() | |
| }); | |
| const [imported] = ConversationTransferService.parseSessionsJsonl(jsonl); | |
| return { imported, sessionLine: JSON.parse(jsonl.split('\n')[0]) }; | |
| } | |
| describe('conversation export source', () => { | |
| it('reads the database record rather than the stale sidebar list', async () => { | |
| conversationsStore.conversations = [conversation('')]; | |
| fetchMock.mockResolvedValue( | |
| new Map([[CONV_ID, { conv: conversation('a2a'), messages: branchedMessages() }]]) | |
| ); | |
| const [exported] = await conversationsStore.getConversationsForExport([CONV_ID]); | |
| expect(exported.conv.currNode).toBe('a2a'); | |
| expect(conversationsStore.conversations[0].currNode).toBe(''); | |
| }); | |
| it('reads every selected conversation from the database on bulk export', async () => { | |
| conversationsStore.conversations = [conversation(''), conversation('', 'c2')]; | |
| conversationsStore.activeConversation = conversation(''); | |
| fetchMock.mockResolvedValue( | |
| new Map([ | |
| ['c2', { conv: conversation('a1', 'c2'), messages: linearMessages('c2') }], | |
| [CONV_ID, { conv: conversation('a2a'), messages: branchedMessages() }] | |
| ]) | |
| ); | |
| const archive = vi | |
| .spyOn(ConversationTransferService, 'downloadConversationsArchive') | |
| .mockImplementation(() => {}); | |
| await conversationsStore.bulkExportConversations([CONV_ID, 'c2']); | |
| expect(fetchMock).toHaveBeenCalledWith([CONV_ID, 'c2']); | |
| expect(archive).toHaveBeenCalledTimes(1); | |
| const payload = archive.mock.calls[0][0]; | |
| expect(payload.map((entry) => entry.conv.id)).toEqual([CONV_ID, 'c2']); | |
| // Each entry carries its own database currNode. | |
| expect(payload.map((entry) => entry.conv.currNode)).toEqual(['a2a', 'a1']); | |
| expect(payload[1].messages.map((m: DatabaseMessage) => m.id)).toEqual(['root', 'u1', 'a1']); | |
| archive.mockRestore(); | |
| }); | |
| }); | |
| describe('exported conversation branch pointer', () => { | |
| it('carries the database currNode, so the import restores the current branch', () => { | |
| // The user regenerated to create a2b, then switched back to the a2a branch, | |
| // so the stored leaf is NOT the newest message. | |
| const { imported, sessionLine } = roundTrip(conversation('a2a')); | |
| expect(sessionLine.currNode).toBe('a2a'); | |
| expect(displayedIds(imported)).toEqual(['u1', 'a1', 'u2a', 'a2a']); | |
| expect(imported.messages.map((m: DatabaseMessage) => m.id).sort()).toEqual([ | |
| 'a1', | |
| 'a2a', | |
| 'a2b', | |
| 'root', | |
| 'u1', | |
| 'u2a', | |
| 'u2b' | |
| ]); | |
| }); | |
| it('shows every branch on import when the cache entry exported an empty currNode', () => { | |
| const { imported, sessionLine } = roundTrip(conversation('')); | |
| expect(sessionLine.currNode).toBe(''); | |
| expect(displayedIds(imported)).toEqual(['root', 'u1', 'a1', 'u2a', 'a2a', 'u2b', 'a2b']); | |
| }); | |
| }); | |
Xet Storage Details
- Size:
- 5.83 kB
- Xet hash:
- 19d87100cf7950009ca92fd5b9403b534e3d3115071afdff7b3832fca75774ad
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.