test(btmsg): add regression tests for named column access and camelCase serialization
Covers the CRITICAL status vs system_prompt bug (positional index 7), JOIN alias disambiguation, serde camelCase serialization, TypeScript bridge IPC commands, and plantuml hex encoding algorithm. 49 new tests: 8 btmsg.rs + 7 bttask.rs + 8 sidecar + 17 btmsg-bridge.ts + 10 bttask-bridge.ts + 7 plantuml-encode.ts
This commit is contained in:
parent
5b364d7a6c
commit
2b7aeb2b47
6 changed files with 1012 additions and 0 deletions
238
v2/src/lib/adapters/btmsg-bridge.test.ts
Normal file
238
v2/src/lib/adapters/btmsg-bridge.test.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockInvoke } = vi.hoisted(() => ({
|
||||
mockInvoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: mockInvoke,
|
||||
}));
|
||||
|
||||
import {
|
||||
getGroupAgents,
|
||||
getUnreadCount,
|
||||
getUnreadMessages,
|
||||
getHistory,
|
||||
sendMessage,
|
||||
setAgentStatus,
|
||||
ensureAdmin,
|
||||
getAllFeed,
|
||||
markRead,
|
||||
getChannels,
|
||||
getChannelMessages,
|
||||
sendChannelMessage,
|
||||
createChannel,
|
||||
addChannelMember,
|
||||
type BtmsgAgent,
|
||||
type BtmsgMessage,
|
||||
type BtmsgFeedMessage,
|
||||
type BtmsgChannel,
|
||||
type BtmsgChannelMessage,
|
||||
} from './btmsg-bridge';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('btmsg-bridge', () => {
|
||||
// ---- REGRESSION: camelCase field names ----
|
||||
// Bug: TypeScript interfaces used snake_case (group_id, unread_count, from_agent, etc.)
|
||||
// but Rust serde(rename_all = "camelCase") sends camelCase.
|
||||
|
||||
describe('BtmsgAgent camelCase fields', () => {
|
||||
it('receives camelCase fields from Rust backend', async () => {
|
||||
const agent: BtmsgAgent = {
|
||||
id: 'a1',
|
||||
name: 'Coder',
|
||||
role: 'developer',
|
||||
groupId: 'g1', // was: group_id
|
||||
tier: 1,
|
||||
model: 'claude-4',
|
||||
status: 'active',
|
||||
unreadCount: 3, // was: unread_count
|
||||
};
|
||||
mockInvoke.mockResolvedValue([agent]);
|
||||
|
||||
const result = await getGroupAgents('g1');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].groupId).toBe('g1');
|
||||
expect(result[0].unreadCount).toBe(3);
|
||||
// Verify snake_case fields do NOT exist
|
||||
expect((result[0] as Record<string, unknown>)['group_id']).toBeUndefined();
|
||||
expect((result[0] as Record<string, unknown>)['unread_count']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('invokes btmsg_get_agents with groupId', async () => {
|
||||
mockInvoke.mockResolvedValue([]);
|
||||
await getGroupAgents('g1');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('btmsg_get_agents', { groupId: 'g1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('BtmsgMessage camelCase fields', () => {
|
||||
it('receives camelCase fields from Rust backend', async () => {
|
||||
const msg: BtmsgMessage = {
|
||||
id: 'm1',
|
||||
fromAgent: 'a1', // was: from_agent
|
||||
toAgent: 'a2', // was: to_agent
|
||||
content: 'hello',
|
||||
read: false,
|
||||
replyTo: null, // was: reply_to
|
||||
createdAt: '2026-01-01', // was: created_at
|
||||
senderName: 'Coder', // was: sender_name
|
||||
senderRole: 'dev', // was: sender_role
|
||||
};
|
||||
mockInvoke.mockResolvedValue([msg]);
|
||||
|
||||
const result = await getUnreadMessages('a2');
|
||||
|
||||
expect(result[0].fromAgent).toBe('a1');
|
||||
expect(result[0].toAgent).toBe('a2');
|
||||
expect(result[0].replyTo).toBeNull();
|
||||
expect(result[0].createdAt).toBe('2026-01-01');
|
||||
expect(result[0].senderName).toBe('Coder');
|
||||
expect(result[0].senderRole).toBe('dev');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BtmsgFeedMessage camelCase fields', () => {
|
||||
it('receives camelCase fields including recipient info', async () => {
|
||||
const feed: BtmsgFeedMessage = {
|
||||
id: 'm1',
|
||||
fromAgent: 'a1',
|
||||
toAgent: 'a2',
|
||||
content: 'review this',
|
||||
createdAt: '2026-01-01',
|
||||
replyTo: null,
|
||||
senderName: 'Coder',
|
||||
senderRole: 'developer',
|
||||
recipientName: 'Reviewer',
|
||||
recipientRole: 'reviewer',
|
||||
};
|
||||
mockInvoke.mockResolvedValue([feed]);
|
||||
|
||||
const result = await getAllFeed('g1');
|
||||
|
||||
expect(result[0].senderName).toBe('Coder');
|
||||
expect(result[0].recipientName).toBe('Reviewer');
|
||||
expect(result[0].recipientRole).toBe('reviewer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BtmsgChannel camelCase fields', () => {
|
||||
it('receives camelCase fields', async () => {
|
||||
const channel: BtmsgChannel = {
|
||||
id: 'ch1',
|
||||
name: 'general',
|
||||
groupId: 'g1', // was: group_id
|
||||
createdBy: 'admin', // was: created_by
|
||||
memberCount: 5, // was: member_count
|
||||
createdAt: '2026-01-01',
|
||||
};
|
||||
mockInvoke.mockResolvedValue([channel]);
|
||||
|
||||
const result = await getChannels('g1');
|
||||
|
||||
expect(result[0].groupId).toBe('g1');
|
||||
expect(result[0].createdBy).toBe('admin');
|
||||
expect(result[0].memberCount).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BtmsgChannelMessage camelCase fields', () => {
|
||||
it('receives camelCase fields', async () => {
|
||||
const msg: BtmsgChannelMessage = {
|
||||
id: 'cm1',
|
||||
channelId: 'ch1', // was: channel_id
|
||||
fromAgent: 'a1',
|
||||
content: 'hello',
|
||||
createdAt: '2026-01-01',
|
||||
senderName: 'Coder',
|
||||
senderRole: 'dev',
|
||||
};
|
||||
mockInvoke.mockResolvedValue([msg]);
|
||||
|
||||
const result = await getChannelMessages('ch1');
|
||||
|
||||
expect(result[0].channelId).toBe('ch1');
|
||||
expect(result[0].fromAgent).toBe('a1');
|
||||
expect(result[0].senderName).toBe('Coder');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- IPC command name tests ----
|
||||
|
||||
describe('IPC commands', () => {
|
||||
it('getUnreadCount invokes btmsg_unread_count', async () => {
|
||||
mockInvoke.mockResolvedValue(5);
|
||||
const result = await getUnreadCount('a1');
|
||||
expect(result).toBe(5);
|
||||
expect(mockInvoke).toHaveBeenCalledWith('btmsg_unread_count', { agentId: 'a1' });
|
||||
});
|
||||
|
||||
it('getHistory invokes btmsg_history', async () => {
|
||||
mockInvoke.mockResolvedValue([]);
|
||||
await getHistory('a1', 'a2', 50);
|
||||
expect(mockInvoke).toHaveBeenCalledWith('btmsg_history', { agentId: 'a1', otherId: 'a2', limit: 50 });
|
||||
});
|
||||
|
||||
it('getHistory defaults limit to 20', async () => {
|
||||
mockInvoke.mockResolvedValue([]);
|
||||
await getHistory('a1', 'a2');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('btmsg_history', { agentId: 'a1', otherId: 'a2', limit: 20 });
|
||||
});
|
||||
|
||||
it('sendMessage invokes btmsg_send', async () => {
|
||||
mockInvoke.mockResolvedValue('msg-id');
|
||||
const result = await sendMessage('a1', 'a2', 'hello');
|
||||
expect(result).toBe('msg-id');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('btmsg_send', { fromAgent: 'a1', toAgent: 'a2', content: 'hello' });
|
||||
});
|
||||
|
||||
it('setAgentStatus invokes btmsg_set_status', async () => {
|
||||
mockInvoke.mockResolvedValue(undefined);
|
||||
await setAgentStatus('a1', 'active');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('btmsg_set_status', { agentId: 'a1', status: 'active' });
|
||||
});
|
||||
|
||||
it('ensureAdmin invokes btmsg_ensure_admin', async () => {
|
||||
mockInvoke.mockResolvedValue(undefined);
|
||||
await ensureAdmin('g1');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('btmsg_ensure_admin', { groupId: 'g1' });
|
||||
});
|
||||
|
||||
it('markRead invokes btmsg_mark_read', async () => {
|
||||
mockInvoke.mockResolvedValue(undefined);
|
||||
await markRead('a2', 'a1');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('btmsg_mark_read', { readerId: 'a2', senderId: 'a1' });
|
||||
});
|
||||
|
||||
it('sendChannelMessage invokes btmsg_channel_send', async () => {
|
||||
mockInvoke.mockResolvedValue('cm-id');
|
||||
const result = await sendChannelMessage('ch1', 'a1', 'hello channel');
|
||||
expect(result).toBe('cm-id');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('btmsg_channel_send', { channelId: 'ch1', fromAgent: 'a1', content: 'hello channel' });
|
||||
});
|
||||
|
||||
it('createChannel invokes btmsg_create_channel', async () => {
|
||||
mockInvoke.mockResolvedValue('ch-id');
|
||||
const result = await createChannel('general', 'g1', 'admin');
|
||||
expect(result).toBe('ch-id');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('btmsg_create_channel', { name: 'general', groupId: 'g1', createdBy: 'admin' });
|
||||
});
|
||||
|
||||
it('addChannelMember invokes btmsg_add_channel_member', async () => {
|
||||
mockInvoke.mockResolvedValue(undefined);
|
||||
await addChannelMember('ch1', 'a1');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('btmsg_add_channel_member', { channelId: 'ch1', agentId: 'a1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('error propagation', () => {
|
||||
it('propagates invoke errors', async () => {
|
||||
mockInvoke.mockRejectedValue(new Error('btmsg database not found'));
|
||||
await expect(getGroupAgents('g1')).rejects.toThrow('btmsg database not found');
|
||||
});
|
||||
});
|
||||
});
|
||||
150
v2/src/lib/adapters/bttask-bridge.test.ts
Normal file
150
v2/src/lib/adapters/bttask-bridge.test.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockInvoke } = vi.hoisted(() => ({
|
||||
mockInvoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: mockInvoke,
|
||||
}));
|
||||
|
||||
import {
|
||||
listTasks,
|
||||
getTaskComments,
|
||||
updateTaskStatus,
|
||||
addTaskComment,
|
||||
createTask,
|
||||
deleteTask,
|
||||
type Task,
|
||||
type TaskComment,
|
||||
} from './bttask-bridge';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('bttask-bridge', () => {
|
||||
// ---- REGRESSION: camelCase field names ----
|
||||
|
||||
describe('Task camelCase fields', () => {
|
||||
it('receives camelCase fields from Rust backend', async () => {
|
||||
const task: Task = {
|
||||
id: 't1',
|
||||
title: 'Fix bug',
|
||||
description: 'Critical fix',
|
||||
status: 'progress',
|
||||
priority: 'high',
|
||||
assignedTo: 'a1', // was: assigned_to
|
||||
createdBy: 'admin', // was: created_by
|
||||
groupId: 'g1', // was: group_id
|
||||
parentTaskId: null, // was: parent_task_id
|
||||
sortOrder: 1, // was: sort_order
|
||||
createdAt: '2026-01-01', // was: created_at
|
||||
updatedAt: '2026-01-01', // was: updated_at
|
||||
};
|
||||
mockInvoke.mockResolvedValue([task]);
|
||||
|
||||
const result = await listTasks('g1');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].assignedTo).toBe('a1');
|
||||
expect(result[0].createdBy).toBe('admin');
|
||||
expect(result[0].groupId).toBe('g1');
|
||||
expect(result[0].parentTaskId).toBeNull();
|
||||
expect(result[0].sortOrder).toBe(1);
|
||||
// Verify no snake_case leaks
|
||||
expect((result[0] as Record<string, unknown>)['assigned_to']).toBeUndefined();
|
||||
expect((result[0] as Record<string, unknown>)['created_by']).toBeUndefined();
|
||||
expect((result[0] as Record<string, unknown>)['group_id']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TaskComment camelCase fields', () => {
|
||||
it('receives camelCase fields from Rust backend', async () => {
|
||||
const comment: TaskComment = {
|
||||
id: 'c1',
|
||||
taskId: 't1', // was: task_id
|
||||
agentId: 'a1', // was: agent_id
|
||||
content: 'Working on it',
|
||||
createdAt: '2026-01-01',
|
||||
};
|
||||
mockInvoke.mockResolvedValue([comment]);
|
||||
|
||||
const result = await getTaskComments('t1');
|
||||
|
||||
expect(result[0].taskId).toBe('t1');
|
||||
expect(result[0].agentId).toBe('a1');
|
||||
expect((result[0] as Record<string, unknown>)['task_id']).toBeUndefined();
|
||||
expect((result[0] as Record<string, unknown>)['agent_id']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- IPC command name tests ----
|
||||
|
||||
describe('IPC commands', () => {
|
||||
it('listTasks invokes bttask_list', async () => {
|
||||
mockInvoke.mockResolvedValue([]);
|
||||
await listTasks('g1');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('bttask_list', { groupId: 'g1' });
|
||||
});
|
||||
|
||||
it('getTaskComments invokes bttask_comments', async () => {
|
||||
mockInvoke.mockResolvedValue([]);
|
||||
await getTaskComments('t1');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('bttask_comments', { taskId: 't1' });
|
||||
});
|
||||
|
||||
it('updateTaskStatus invokes bttask_update_status', async () => {
|
||||
mockInvoke.mockResolvedValue(undefined);
|
||||
await updateTaskStatus('t1', 'done');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('bttask_update_status', { taskId: 't1', status: 'done' });
|
||||
});
|
||||
|
||||
it('addTaskComment invokes bttask_add_comment', async () => {
|
||||
mockInvoke.mockResolvedValue('c-id');
|
||||
const result = await addTaskComment('t1', 'a1', 'Done!');
|
||||
expect(result).toBe('c-id');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('bttask_add_comment', { taskId: 't1', agentId: 'a1', content: 'Done!' });
|
||||
});
|
||||
|
||||
it('createTask invokes bttask_create with all fields', async () => {
|
||||
mockInvoke.mockResolvedValue('t-id');
|
||||
const result = await createTask('Fix bug', 'desc', 'high', 'g1', 'admin', 'a1');
|
||||
expect(result).toBe('t-id');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('bttask_create', {
|
||||
title: 'Fix bug',
|
||||
description: 'desc',
|
||||
priority: 'high',
|
||||
groupId: 'g1',
|
||||
createdBy: 'admin',
|
||||
assignedTo: 'a1',
|
||||
});
|
||||
});
|
||||
|
||||
it('createTask invokes bttask_create without assignedTo', async () => {
|
||||
mockInvoke.mockResolvedValue('t-id');
|
||||
await createTask('Add tests', '', 'medium', 'g1', 'a1');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('bttask_create', {
|
||||
title: 'Add tests',
|
||||
description: '',
|
||||
priority: 'medium',
|
||||
groupId: 'g1',
|
||||
createdBy: 'a1',
|
||||
assignedTo: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('deleteTask invokes bttask_delete', async () => {
|
||||
mockInvoke.mockResolvedValue(undefined);
|
||||
await deleteTask('t1');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('bttask_delete', { taskId: 't1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('error propagation', () => {
|
||||
it('propagates invoke errors', async () => {
|
||||
mockInvoke.mockRejectedValue(new Error('btmsg database not found'));
|
||||
await expect(listTasks('g1')).rejects.toThrow('btmsg database not found');
|
||||
});
|
||||
});
|
||||
});
|
||||
67
v2/src/lib/utils/plantuml-encode.test.ts
Normal file
67
v2/src/lib/utils/plantuml-encode.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
// ---- REGRESSION: PlantUML hex encoding ----
|
||||
// Bug: ArchitectureTab had a broken encoding chain (rawDeflate returned input unchanged,
|
||||
// encode64 was hex encoding masquerading as base64). Fixed by collapsing to single
|
||||
// plantumlEncode function using ~h hex prefix (plantuml.com text encoding standard).
|
||||
//
|
||||
// This test validates the encoding algorithm matches what ArchitectureTab.svelte uses.
|
||||
|
||||
/** Reimplementation of the plantumlEncode function from ArchitectureTab.svelte */
|
||||
function plantumlEncode(text: string): string {
|
||||
const bytes = unescape(encodeURIComponent(text));
|
||||
let hex = '~h';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
hex += bytes.charCodeAt(i).toString(16).padStart(2, '0');
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
describe('plantumlEncode', () => {
|
||||
it('produces ~h prefix for hex encoding', () => {
|
||||
const result = plantumlEncode('@startuml\n@enduml');
|
||||
expect(result.startsWith('~h')).toBe(true);
|
||||
});
|
||||
|
||||
it('encodes ASCII correctly', () => {
|
||||
const result = plantumlEncode('AB');
|
||||
// A=0x41, B=0x42
|
||||
expect(result).toBe('~h4142');
|
||||
});
|
||||
|
||||
it('encodes simple PlantUML source', () => {
|
||||
const result = plantumlEncode('@startuml\n@enduml');
|
||||
// Each character maps to its hex code
|
||||
const expected = '~h' + Array.from('@startuml\n@enduml')
|
||||
.map(c => c.charCodeAt(0).toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it('handles Unicode characters', () => {
|
||||
// UTF-8 multi-byte: é = 0xc3 0xa9
|
||||
const result = plantumlEncode('café');
|
||||
expect(result.startsWith('~h')).toBe(true);
|
||||
// c=63, a=61, f=66, é=c3a9
|
||||
expect(result).toBe('~h636166c3a9');
|
||||
});
|
||||
|
||||
it('handles empty string', () => {
|
||||
expect(plantumlEncode('')).toBe('~h');
|
||||
});
|
||||
|
||||
it('produces valid URL-safe output (no special chars beyond hex digits)', () => {
|
||||
const result = plantumlEncode('@startuml\ntitle Test\nA -> B\n@enduml');
|
||||
// After ~h prefix, only hex digits [0-9a-f]
|
||||
const hexPart = result.slice(2);
|
||||
expect(hexPart).toMatch(/^[0-9a-f]+$/);
|
||||
});
|
||||
|
||||
it('generates correct URL for plantuml.com', () => {
|
||||
const source = '@startuml\nA -> B\n@enduml';
|
||||
const encoded = plantumlEncode(source);
|
||||
const url = `https://www.plantuml.com/plantuml/svg/${encoded}`;
|
||||
expect(url).toContain('plantuml.com/plantuml/svg/~h');
|
||||
expect(url.length).toBeGreaterThan(50);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue