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:
Hibryda 2026-03-11 22:19:03 +01:00
parent a12f2bec7b
commit e41d237745
6 changed files with 1012 additions and 0 deletions

View 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');
});
});
});

View 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');
});
});
});

View 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);
});
});