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
a12f2bec7b
commit
e41d237745
6 changed files with 1012 additions and 0 deletions
|
|
@ -409,3 +409,368 @@ pub fn add_channel_member(channel_id: &str, agent_id: &str) -> Result<(), String
|
|||
).map_err(|e| format!("Insert error: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
|
||||
/// Create an in-memory DB with the btmsg schema for testing.
|
||||
fn test_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
group_id TEXT NOT NULL,
|
||||
tier INTEGER NOT NULL DEFAULT 1,
|
||||
model TEXT,
|
||||
cwd TEXT,
|
||||
system_prompt TEXT,
|
||||
status TEXT DEFAULT 'stopped',
|
||||
last_active_at TEXT
|
||||
);
|
||||
CREATE TABLE messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
from_agent TEXT NOT NULL,
|
||||
to_agent TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
group_id TEXT NOT NULL,
|
||||
read INTEGER DEFAULT 0,
|
||||
reply_to TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE contacts (
|
||||
agent_id TEXT NOT NULL,
|
||||
contact_id TEXT NOT NULL,
|
||||
PRIMARY KEY (agent_id, contact_id)
|
||||
);
|
||||
CREATE TABLE channels (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
group_id TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE channel_members (
|
||||
channel_id TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
PRIMARY KEY (channel_id, agent_id)
|
||||
);
|
||||
CREATE TABLE channel_messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL,
|
||||
from_agent TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);",
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
fn seed_agents(conn: &Connection) {
|
||||
conn.execute(
|
||||
"INSERT INTO agents (id, name, role, group_id, tier, model, system_prompt, status)
|
||||
VALUES ('a1', 'Coder', 'developer', 'g1', 1, 'claude-4', 'You are a coder', 'active')",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO agents (id, name, role, group_id, tier, model, system_prompt, status)
|
||||
VALUES ('a2', 'Reviewer', 'reviewer', 'g1', 2, NULL, 'You review code', 'sleeping')",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO agents (id, name, role, group_id, tier, model, system_prompt, status)
|
||||
VALUES ('admin', 'Operator', 'admin', 'g1', 0, NULL, NULL, 'active')",
|
||||
[],
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
// ---- CRITICAL REGRESSION: get_agents named column access ----
|
||||
// Bug: positional index 7 returned system_prompt instead of status (column 8).
|
||||
// Fix: named access via row.get("status").
|
||||
|
||||
#[test]
|
||||
fn test_get_agents_returns_status_not_system_prompt() {
|
||||
let conn = test_db();
|
||||
seed_agents(&conn);
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT a.*, (SELECT COUNT(*) FROM messages m WHERE m.to_agent = a.id AND m.read = 0) as unread_count \
|
||||
FROM agents a WHERE a.group_id = ? ORDER BY a.tier, a.role, a.name"
|
||||
).unwrap();
|
||||
|
||||
let agents: Vec<BtmsgAgent> = stmt.query_map(params!["g1"], |row| {
|
||||
Ok(BtmsgAgent {
|
||||
id: row.get("id")?,
|
||||
name: row.get("name")?,
|
||||
role: row.get("role")?,
|
||||
group_id: row.get("group_id")?,
|
||||
tier: row.get("tier")?,
|
||||
model: row.get("model")?,
|
||||
status: row.get::<_, Option<String>>("status")?.unwrap_or_else(|| "stopped".into()),
|
||||
unread_count: row.get("unread_count")?,
|
||||
})
|
||||
}).unwrap().collect::<Result<Vec<_>, _>>().unwrap();
|
||||
|
||||
assert_eq!(agents.len(), 3);
|
||||
|
||||
// admin (tier 0) comes first
|
||||
assert_eq!(agents[0].id, "admin");
|
||||
assert_eq!(agents[0].status, "active");
|
||||
assert_eq!(agents[0].tier, 0);
|
||||
|
||||
// Coder — status must be "active", NOT "You are a coder" (system_prompt)
|
||||
let coder = agents.iter().find(|a| a.id == "a1").unwrap();
|
||||
assert_eq!(coder.status, "active");
|
||||
assert_eq!(coder.name, "Coder");
|
||||
assert_eq!(coder.model, Some("claude-4".to_string()));
|
||||
|
||||
// Reviewer — status must be "sleeping", NOT "You review code" (system_prompt)
|
||||
let reviewer = agents.iter().find(|a| a.id == "a2").unwrap();
|
||||
assert_eq!(reviewer.status, "sleeping");
|
||||
assert_eq!(reviewer.model, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_agents_unread_count() {
|
||||
let conn = test_db();
|
||||
seed_agents(&conn);
|
||||
|
||||
// Send 2 unread messages to a1
|
||||
conn.execute(
|
||||
"INSERT INTO messages (id, from_agent, to_agent, content, group_id, read) VALUES ('m1', 'a2', 'a1', 'hi', 'g1', 0)",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO messages (id, from_agent, to_agent, content, group_id, read) VALUES ('m2', 'admin', 'a1', 'task', 'g1', 0)",
|
||||
[],
|
||||
).unwrap();
|
||||
// Send 1 read message to a1
|
||||
conn.execute(
|
||||
"INSERT INTO messages (id, from_agent, to_agent, content, group_id, read) VALUES ('m3', 'a2', 'a1', 'old', 'g1', 1)",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT a.*, (SELECT COUNT(*) FROM messages m WHERE m.to_agent = a.id AND m.read = 0) as unread_count \
|
||||
FROM agents a WHERE a.group_id = ? ORDER BY a.tier, a.role, a.name"
|
||||
).unwrap();
|
||||
|
||||
let agents: Vec<BtmsgAgent> = stmt.query_map(params!["g1"], |row| {
|
||||
Ok(BtmsgAgent {
|
||||
id: row.get("id")?,
|
||||
name: row.get("name")?,
|
||||
role: row.get("role")?,
|
||||
group_id: row.get("group_id")?,
|
||||
tier: row.get("tier")?,
|
||||
model: row.get("model")?,
|
||||
status: row.get::<_, Option<String>>("status")?.unwrap_or_else(|| "stopped".into()),
|
||||
unread_count: row.get("unread_count")?,
|
||||
})
|
||||
}).unwrap().collect::<Result<Vec<_>, _>>().unwrap();
|
||||
|
||||
let coder = agents.iter().find(|a| a.id == "a1").unwrap();
|
||||
assert_eq!(coder.unread_count, 2);
|
||||
|
||||
let reviewer = agents.iter().find(|a| a.id == "a2").unwrap();
|
||||
assert_eq!(reviewer.unread_count, 0);
|
||||
}
|
||||
|
||||
// ---- REGRESSION: all_feed JOIN alias disambiguation ----
|
||||
// Bug: JOINed queries had duplicate "name" columns. Fixed with AS aliases.
|
||||
|
||||
#[test]
|
||||
fn test_all_feed_returns_correct_sender_and_recipient_names() {
|
||||
let conn = test_db();
|
||||
seed_agents(&conn);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO messages (id, from_agent, to_agent, content, group_id) VALUES ('m1', 'a1', 'a2', 'review this', 'g1')",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT m.id, m.from_agent, m.to_agent, m.content, m.created_at, m.reply_to, \
|
||||
a1.name AS sender_name, a1.role AS sender_role, \
|
||||
a2.name AS recipient_name, a2.role AS recipient_role \
|
||||
FROM messages m \
|
||||
JOIN agents a1 ON m.from_agent = a1.id \
|
||||
JOIN agents a2 ON m.to_agent = a2.id \
|
||||
WHERE m.group_id = ? \
|
||||
ORDER BY m.created_at DESC LIMIT ?"
|
||||
).unwrap();
|
||||
|
||||
let msgs: Vec<BtmsgFeedMessage> = stmt.query_map(params!["g1", 100], |row| {
|
||||
Ok(BtmsgFeedMessage {
|
||||
id: row.get("id")?,
|
||||
from_agent: row.get("from_agent")?,
|
||||
to_agent: row.get("to_agent")?,
|
||||
content: row.get("content")?,
|
||||
created_at: row.get("created_at")?,
|
||||
reply_to: row.get("reply_to")?,
|
||||
sender_name: row.get("sender_name")?,
|
||||
sender_role: row.get("sender_role")?,
|
||||
recipient_name: row.get("recipient_name")?,
|
||||
recipient_role: row.get("recipient_role")?,
|
||||
})
|
||||
}).unwrap().collect::<Result<Vec<_>, _>>().unwrap();
|
||||
|
||||
assert_eq!(msgs.len(), 1);
|
||||
assert_eq!(msgs[0].sender_name, "Coder");
|
||||
assert_eq!(msgs[0].sender_role, "developer");
|
||||
assert_eq!(msgs[0].recipient_name, "Reviewer");
|
||||
assert_eq!(msgs[0].recipient_role, "reviewer");
|
||||
}
|
||||
|
||||
// ---- REGRESSION: unread_messages JOIN alias ----
|
||||
|
||||
#[test]
|
||||
fn test_unread_messages_returns_sender_info() {
|
||||
let conn = test_db();
|
||||
seed_agents(&conn);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO messages (id, from_agent, to_agent, content, group_id, read) VALUES ('m1', 'a1', 'a2', 'check this', 'g1', 0)",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT m.id, m.from_agent, m.to_agent, m.content, m.read, m.reply_to, m.created_at, \
|
||||
a.name AS sender_name, a.role AS sender_role \
|
||||
FROM messages m JOIN agents a ON m.from_agent = a.id \
|
||||
WHERE m.to_agent = ? AND m.read = 0 ORDER BY m.created_at ASC"
|
||||
).unwrap();
|
||||
|
||||
let msgs: Vec<BtmsgMessage> = stmt.query_map(params!["a2"], |row| {
|
||||
Ok(BtmsgMessage {
|
||||
id: row.get("id")?,
|
||||
from_agent: row.get("from_agent")?,
|
||||
to_agent: row.get("to_agent")?,
|
||||
content: row.get("content")?,
|
||||
read: row.get::<_, i32>("read")? != 0,
|
||||
reply_to: row.get("reply_to")?,
|
||||
created_at: row.get("created_at")?,
|
||||
sender_name: row.get("sender_name")?,
|
||||
sender_role: row.get("sender_role")?,
|
||||
})
|
||||
}).unwrap().collect::<Result<Vec<_>, _>>().unwrap();
|
||||
|
||||
assert_eq!(msgs.len(), 1);
|
||||
assert_eq!(msgs[0].sender_name, Some("Coder".to_string()));
|
||||
assert_eq!(msgs[0].sender_role, Some("developer".to_string()));
|
||||
assert!(!msgs[0].read);
|
||||
}
|
||||
|
||||
// ---- REGRESSION: channel_messages JOIN alias ----
|
||||
|
||||
#[test]
|
||||
fn test_channel_messages_returns_sender_info() {
|
||||
let conn = test_db();
|
||||
seed_agents(&conn);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO channels (id, name, group_id, created_by) VALUES ('ch1', 'general', 'g1', 'admin')",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO channel_members (channel_id, agent_id) VALUES ('ch1', 'a1')",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO channel_messages (id, channel_id, from_agent, content) VALUES ('cm1', 'ch1', 'a1', 'hello channel')",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT cm.id, cm.channel_id, cm.from_agent, cm.content, cm.created_at, \
|
||||
a.name AS sender_name, a.role AS sender_role \
|
||||
FROM channel_messages cm JOIN agents a ON cm.from_agent = a.id \
|
||||
WHERE cm.channel_id = ? ORDER BY cm.created_at ASC LIMIT ?"
|
||||
).unwrap();
|
||||
|
||||
let msgs: Vec<BtmsgChannelMessage> = stmt.query_map(params!["ch1", 100], |row| {
|
||||
Ok(BtmsgChannelMessage {
|
||||
id: row.get("id")?,
|
||||
channel_id: row.get("channel_id")?,
|
||||
from_agent: row.get("from_agent")?,
|
||||
content: row.get("content")?,
|
||||
created_at: row.get("created_at")?,
|
||||
sender_name: row.get("sender_name")?,
|
||||
sender_role: row.get("sender_role")?,
|
||||
})
|
||||
}).unwrap().collect::<Result<Vec<_>, _>>().unwrap();
|
||||
|
||||
assert_eq!(msgs.len(), 1);
|
||||
assert_eq!(msgs[0].sender_name, "Coder");
|
||||
assert_eq!(msgs[0].sender_role, "developer");
|
||||
}
|
||||
|
||||
// ---- serde camelCase serialization ----
|
||||
|
||||
#[test]
|
||||
fn test_btmsg_agent_serializes_to_camel_case() {
|
||||
let agent = BtmsgAgent {
|
||||
id: "a1".into(),
|
||||
name: "Test".into(),
|
||||
role: "dev".into(),
|
||||
group_id: "g1".into(),
|
||||
tier: 1,
|
||||
model: None,
|
||||
status: "active".into(),
|
||||
unread_count: 5,
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&agent).unwrap();
|
||||
// Verify camelCase keys (matches TypeScript interface)
|
||||
assert!(json.get("groupId").is_some(), "expected camelCase 'groupId'");
|
||||
assert!(json.get("unreadCount").is_some(), "expected camelCase 'unreadCount'");
|
||||
assert!(json.get("group_id").is_none(), "should not have snake_case 'group_id'");
|
||||
assert!(json.get("unread_count").is_none(), "should not have snake_case 'unread_count'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_btmsg_message_serializes_to_camel_case() {
|
||||
let msg = BtmsgMessage {
|
||||
id: "m1".into(),
|
||||
from_agent: "a1".into(),
|
||||
to_agent: "a2".into(),
|
||||
content: "hi".into(),
|
||||
read: false,
|
||||
reply_to: None,
|
||||
created_at: "2026-01-01".into(),
|
||||
sender_name: Some("Coder".into()),
|
||||
sender_role: Some("dev".into()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&msg).unwrap();
|
||||
assert!(json.get("fromAgent").is_some(), "expected camelCase 'fromAgent'");
|
||||
assert!(json.get("toAgent").is_some(), "expected camelCase 'toAgent'");
|
||||
assert!(json.get("replyTo").is_some(), "expected camelCase 'replyTo'");
|
||||
assert!(json.get("createdAt").is_some(), "expected camelCase 'createdAt'");
|
||||
assert!(json.get("senderName").is_some(), "expected camelCase 'senderName'");
|
||||
assert!(json.get("senderRole").is_some(), "expected camelCase 'senderRole'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_btmsg_feed_message_serializes_to_camel_case() {
|
||||
let msg = BtmsgFeedMessage {
|
||||
id: "m1".into(),
|
||||
from_agent: "a1".into(),
|
||||
to_agent: "a2".into(),
|
||||
content: "hi".into(),
|
||||
created_at: "2026-01-01".into(),
|
||||
reply_to: None,
|
||||
sender_name: "Coder".into(),
|
||||
sender_role: "dev".into(),
|
||||
recipient_name: "Reviewer".into(),
|
||||
recipient_role: "reviewer".into(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&msg).unwrap();
|
||||
assert!(json.get("recipientName").is_some(), "expected camelCase 'recipientName'");
|
||||
assert!(json.get("recipientRole").is_some(), "expected camelCase 'recipientRole'");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -172,3 +172,194 @@ pub fn delete_task(task_id: &str) -> Result<(), String> {
|
|||
.map_err(|e| format!("Delete task error: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
|
||||
fn test_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
status TEXT DEFAULT 'todo',
|
||||
priority TEXT DEFAULT 'medium',
|
||||
assigned_to TEXT,
|
||||
created_by TEXT NOT NULL,
|
||||
group_id TEXT NOT NULL,
|
||||
parent_task_id TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE task_comments (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);",
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
// ---- REGRESSION: list_tasks named column access ----
|
||||
|
||||
#[test]
|
||||
fn test_list_tasks_named_column_access() {
|
||||
let conn = test_db();
|
||||
conn.execute(
|
||||
"INSERT INTO tasks (id, title, description, status, priority, assigned_to, created_by, group_id, sort_order)
|
||||
VALUES ('t1', 'Fix bug', 'Critical fix', 'progress', 'high', 'a1', 'admin', 'g1', 1)",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO tasks (id, title, description, status, priority, assigned_to, created_by, group_id, sort_order)
|
||||
VALUES ('t2', 'Add tests', '', 'todo', 'medium', NULL, 'a1', 'g1', 2)",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, title, description, status, priority, assigned_to,
|
||||
created_by, group_id, parent_task_id, sort_order,
|
||||
created_at, updated_at
|
||||
FROM tasks WHERE group_id = ?1
|
||||
ORDER BY sort_order ASC, created_at DESC",
|
||||
).unwrap();
|
||||
|
||||
let tasks: Vec<Task> = stmt.query_map(params!["g1"], |row| {
|
||||
Ok(Task {
|
||||
id: row.get("id")?,
|
||||
title: row.get("title")?,
|
||||
description: row.get::<_, String>("description").unwrap_or_default(),
|
||||
status: row.get::<_, String>("status").unwrap_or_else(|_| "todo".into()),
|
||||
priority: row.get::<_, String>("priority").unwrap_or_else(|_| "medium".into()),
|
||||
assigned_to: row.get("assigned_to")?,
|
||||
created_by: row.get("created_by")?,
|
||||
group_id: row.get("group_id")?,
|
||||
parent_task_id: row.get("parent_task_id")?,
|
||||
sort_order: row.get::<_, i32>("sort_order").unwrap_or(0),
|
||||
created_at: row.get::<_, String>("created_at").unwrap_or_default(),
|
||||
updated_at: row.get::<_, String>("updated_at").unwrap_or_default(),
|
||||
})
|
||||
}).unwrap().collect::<Result<Vec<_>, _>>().unwrap();
|
||||
|
||||
assert_eq!(tasks.len(), 2);
|
||||
assert_eq!(tasks[0].id, "t1");
|
||||
assert_eq!(tasks[0].title, "Fix bug");
|
||||
assert_eq!(tasks[0].status, "progress");
|
||||
assert_eq!(tasks[0].priority, "high");
|
||||
assert_eq!(tasks[0].assigned_to, Some("a1".to_string()));
|
||||
assert_eq!(tasks[0].sort_order, 1);
|
||||
|
||||
assert_eq!(tasks[1].id, "t2");
|
||||
assert_eq!(tasks[1].assigned_to, None);
|
||||
assert_eq!(tasks[1].parent_task_id, None);
|
||||
}
|
||||
|
||||
// ---- REGRESSION: task_comments named column access ----
|
||||
|
||||
#[test]
|
||||
fn test_task_comments_named_column_access() {
|
||||
let conn = test_db();
|
||||
conn.execute(
|
||||
"INSERT INTO tasks (id, title, created_by, group_id) VALUES ('t1', 'Test', 'admin', 'g1')",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO task_comments (id, task_id, agent_id, content) VALUES ('c1', 't1', 'a1', 'Working on it')",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO task_comments (id, task_id, agent_id, content) VALUES ('c2', 't1', 'a2', 'Looks good')",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, task_id, agent_id, content, created_at
|
||||
FROM task_comments WHERE task_id = ?1
|
||||
ORDER BY created_at ASC",
|
||||
).unwrap();
|
||||
|
||||
let comments: Vec<TaskComment> = stmt.query_map(params!["t1"], |row| {
|
||||
Ok(TaskComment {
|
||||
id: row.get("id")?,
|
||||
task_id: row.get("task_id")?,
|
||||
agent_id: row.get("agent_id")?,
|
||||
content: row.get("content")?,
|
||||
created_at: row.get::<_, String>("created_at").unwrap_or_default(),
|
||||
})
|
||||
}).unwrap().collect::<Result<Vec<_>, _>>().unwrap();
|
||||
|
||||
assert_eq!(comments.len(), 2);
|
||||
assert_eq!(comments[0].agent_id, "a1");
|
||||
assert_eq!(comments[0].content, "Working on it");
|
||||
assert_eq!(comments[1].agent_id, "a2");
|
||||
}
|
||||
|
||||
// ---- serde camelCase serialization ----
|
||||
|
||||
#[test]
|
||||
fn test_task_serializes_to_camel_case() {
|
||||
let task = Task {
|
||||
id: "t1".into(),
|
||||
title: "Test".into(),
|
||||
description: "desc".into(),
|
||||
status: "todo".into(),
|
||||
priority: "high".into(),
|
||||
assigned_to: Some("a1".into()),
|
||||
created_by: "admin".into(),
|
||||
group_id: "g1".into(),
|
||||
parent_task_id: None,
|
||||
sort_order: 0,
|
||||
created_at: "2026-01-01".into(),
|
||||
updated_at: "2026-01-01".into(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&task).unwrap();
|
||||
assert!(json.get("assignedTo").is_some(), "expected camelCase 'assignedTo'");
|
||||
assert!(json.get("createdBy").is_some(), "expected camelCase 'createdBy'");
|
||||
assert!(json.get("groupId").is_some(), "expected camelCase 'groupId'");
|
||||
assert!(json.get("parentTaskId").is_some(), "expected camelCase 'parentTaskId'");
|
||||
assert!(json.get("sortOrder").is_some(), "expected camelCase 'sortOrder'");
|
||||
assert!(json.get("createdAt").is_some(), "expected camelCase 'createdAt'");
|
||||
assert!(json.get("updatedAt").is_some(), "expected camelCase 'updatedAt'");
|
||||
// Ensure no snake_case leaks
|
||||
assert!(json.get("assigned_to").is_none());
|
||||
assert!(json.get("created_by").is_none());
|
||||
assert!(json.get("group_id").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_task_comment_serializes_to_camel_case() {
|
||||
let comment = TaskComment {
|
||||
id: "c1".into(),
|
||||
task_id: "t1".into(),
|
||||
agent_id: "a1".into(),
|
||||
content: "note".into(),
|
||||
created_at: "2026-01-01".into(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&comment).unwrap();
|
||||
assert!(json.get("taskId").is_some(), "expected camelCase 'taskId'");
|
||||
assert!(json.get("agentId").is_some(), "expected camelCase 'agentId'");
|
||||
assert!(json.get("createdAt").is_some(), "expected camelCase 'createdAt'");
|
||||
assert!(json.get("task_id").is_none());
|
||||
}
|
||||
|
||||
// ---- update_task_status validation ----
|
||||
|
||||
#[test]
|
||||
fn test_update_task_status_rejects_invalid() {
|
||||
// Can't call update_task_status directly (uses open_db), but we can test the validation logic
|
||||
let valid = ["todo", "progress", "review", "done", "blocked"];
|
||||
assert!(valid.contains(&"todo"));
|
||||
assert!(valid.contains(&"done"));
|
||||
assert!(!valid.contains(&"invalid"));
|
||||
assert!(!valid.contains(&"cancelled"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -191,6 +191,7 @@ mod tests {
|
|||
profile: "default".to_string(),
|
||||
enabled: true,
|
||||
}],
|
||||
agents: vec![],
|
||||
}],
|
||||
active_group_id: "test".to_string(),
|
||||
};
|
||||
|
|
|
|||
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