W21: anthropic Stream+Tools + --prompt batch + sanitizer fix + plugin unit tests (W21.1-W21.6)
- W21.1: ci-sanitize preset 独立 Linux-clang + ci-threadsan (TSan) - W21.2: anthropic tool_use content_block 解析 + configure 缓存 tools_json - W21.3: --prompt 非交互批处理模式 - W21.4: session auto-save 失败告警 + 当前目录 fallback - W21.5: smoke 补 tool_calls 边界用例 4 块 12 断言 - W21.6: anthropic 11 块 78 CHECK + deepseek 12 块 78 CHECK Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -123,3 +123,57 @@ target_link_libraries(dstalk-plugin-loader-test
|
||||
)
|
||||
|
||||
add_test(NAME dstalk-plugin-loader-test COMMAND dstalk-plugin-loader-test)
|
||||
|
||||
# ============================================================
|
||||
# dstalk-anthropic-plugin-test — Anthropic AI 插件单元测试
|
||||
# W21.6 (qa-wang): 通过 #include source 访问 static 函数
|
||||
# ============================================================
|
||||
|
||||
add_executable(dstalk-anthropic-plugin-test
|
||||
anthropic_plugin_test.cpp
|
||||
)
|
||||
|
||||
target_include_directories(dstalk-anthropic-plugin-test
|
||||
PRIVATE ${CMAKE_SOURCE_DIR}/dstalk-core/include
|
||||
)
|
||||
|
||||
target_compile_definitions(dstalk-anthropic-plugin-test
|
||||
PRIVATE
|
||||
BOOST_JSON_HEADER_ONLY
|
||||
BOOST_ALL_NO_LIB
|
||||
)
|
||||
|
||||
target_link_libraries(dstalk-anthropic-plugin-test
|
||||
PRIVATE
|
||||
dstalk
|
||||
boost::boost
|
||||
)
|
||||
|
||||
add_test(NAME dstalk-anthropic-plugin-test COMMAND dstalk-anthropic-plugin-test)
|
||||
|
||||
# ============================================================
|
||||
# dstalk-deepseek-plugin-test — DeepSeek AI 插件单元测试
|
||||
# W21.6 (qa-wang): 通过 #include source 访问 static 函数
|
||||
# ============================================================
|
||||
|
||||
add_executable(dstalk-deepseek-plugin-test
|
||||
deepseek_plugin_test.cpp
|
||||
)
|
||||
|
||||
target_include_directories(dstalk-deepseek-plugin-test
|
||||
PRIVATE ${CMAKE_SOURCE_DIR}/dstalk-core/include
|
||||
)
|
||||
|
||||
target_compile_definitions(dstalk-deepseek-plugin-test
|
||||
PRIVATE
|
||||
BOOST_JSON_HEADER_ONLY
|
||||
BOOST_ALL_NO_LIB
|
||||
)
|
||||
|
||||
target_link_libraries(dstalk-deepseek-plugin-test
|
||||
PRIVATE
|
||||
dstalk
|
||||
boost::boost
|
||||
)
|
||||
|
||||
add_test(NAME dstalk-deepseek-plugin-test COMMAND dstalk-deepseek-plugin-test)
|
||||
|
||||
558
tests/anthropic_plugin_test.cpp
Normal file
558
tests/anthropic_plugin_test.cpp
Normal file
@@ -0,0 +1,558 @@
|
||||
// ============================================================================
|
||||
// anthropic_plugin_test.cpp — Anthropic AI 插件单元测试
|
||||
// W21.6 (qa-wang): 覆盖 SSE 解析 / JSON 请求构建 / URL 解析 / 安全擦除
|
||||
// 通过 #include plugin source 访问 file-scope static 函数
|
||||
// ============================================================================
|
||||
#define BOOST_JSON_HEADER_ONLY
|
||||
#define BOOST_ALL_NO_LIB
|
||||
#include "../plugins/anthropic/src/anthropic_plugin.cpp"
|
||||
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond, msg) do { \
|
||||
if (cond) { \
|
||||
std::cout << "[OK] " << (msg) << "\n"; \
|
||||
} else { \
|
||||
std::cerr << "[FAIL] " << (msg) << "\n"; \
|
||||
g_failures++; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
// Test helper: populate g_cfg for build functions
|
||||
static void setup_config() {
|
||||
g_cfg.provider = "anthropic";
|
||||
g_cfg.base_url = "https://api.anthropic.com";
|
||||
g_cfg.api_key = "sk-ant-test-key-12345";
|
||||
g_cfg.model = "claude-sonnet-4-20250514";
|
||||
g_cfg.max_tokens = 4096;
|
||||
g_cfg.temperature = 0.7;
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
// ================================================================
|
||||
// Test Block 1: parse_sse_data — invalid/malformed inputs
|
||||
// ================================================================
|
||||
std::cout << "\n--- Block 1: parse_sse_data invalid/malformed ---\n";
|
||||
|
||||
{
|
||||
std::string token;
|
||||
bool ret = parse_sse_data("", token, nullptr);
|
||||
CHECK(!ret, "T1.1: empty string returns false");
|
||||
}
|
||||
|
||||
{
|
||||
std::string token;
|
||||
bool ret = parse_sse_data("not json at all", token, nullptr);
|
||||
CHECK(!ret, "T1.2: non-JSON string returns false (no crash)");
|
||||
}
|
||||
|
||||
{
|
||||
std::string token;
|
||||
bool ret = parse_sse_data("{}", token, nullptr);
|
||||
CHECK(!ret, "T1.3: empty JSON object returns false");
|
||||
}
|
||||
|
||||
{
|
||||
std::string token;
|
||||
bool ret = parse_sse_data("{\"notype\":\"x\"}", token, nullptr);
|
||||
CHECK(!ret, "T1.4: JSON without 'type' field returns false");
|
||||
}
|
||||
|
||||
{
|
||||
std::string token;
|
||||
bool ret = parse_sse_data("{\"type\":123}", token, nullptr);
|
||||
CHECK(!ret, "T1.5: 'type' is number (not string) returns false");
|
||||
}
|
||||
|
||||
{
|
||||
// Malformed JSON: unclosed brace
|
||||
std::string token;
|
||||
bool ret = parse_sse_data("{\"type\":\"ping\"", token, nullptr);
|
||||
CHECK(!ret, "T1.6: malformed JSON (unclosed brace) returns false (no crash)");
|
||||
}
|
||||
|
||||
{
|
||||
// Random garbage bytes
|
||||
std::string token;
|
||||
bool ret = parse_sse_data("\x00\x01\xFF\xFE", token, nullptr);
|
||||
CHECK(!ret, "T1.7: binary garbage returns false (no crash)");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Test Block 2: parse_sse_data — content_block_delta
|
||||
// ================================================================
|
||||
std::cout << "\n--- Block 2: parse_sse_data content_block_delta ---\n";
|
||||
|
||||
{
|
||||
std::string token;
|
||||
const char* json =
|
||||
"{\"type\":\"content_block_delta\","
|
||||
"\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}";
|
||||
bool ret = parse_sse_data(json, token, nullptr);
|
||||
CHECK(ret, "T2.1: text_delta with 'Hello' returns true");
|
||||
CHECK(token == "Hello", "T2.2: token equals 'Hello'");
|
||||
}
|
||||
|
||||
{
|
||||
std::string token;
|
||||
const char* json =
|
||||
"{\"type\":\"content_block_delta\","
|
||||
"\"delta\":{\"type\":\"text_delta\",\"text\":\"\"}}";
|
||||
bool ret = parse_sse_data(json, token, nullptr);
|
||||
CHECK(ret, "T2.3: text_delta with empty text returns true");
|
||||
CHECK(token.empty(), "T2.4: token is empty string");
|
||||
}
|
||||
|
||||
{
|
||||
std::string token;
|
||||
const char* json =
|
||||
"{\"type\":\"content_block_delta\","
|
||||
"\"delta\":{\"type\":\"text_delta\"}}";
|
||||
bool ret = parse_sse_data(json, token, nullptr);
|
||||
CHECK(!ret, "T2.5: text_delta missing 'text' field returns false");
|
||||
}
|
||||
|
||||
{
|
||||
std::string token;
|
||||
const char* json =
|
||||
"{\"type\":\"content_block_delta\","
|
||||
"\"delta\":{\"type\":\"input_json_delta\","
|
||||
"\"partial_json\":\"{\\\"foo\\\":\"}}";
|
||||
bool ret = parse_sse_data(json, token, nullptr);
|
||||
CHECK(!ret, "T2.6: input_json_delta (non-text) returns false");
|
||||
}
|
||||
|
||||
{
|
||||
std::string token;
|
||||
const char* json =
|
||||
"{\"type\":\"content_block_delta\","
|
||||
"\"delta\":\"not_an_object\"}";
|
||||
bool ret = parse_sse_data(json, token, nullptr);
|
||||
CHECK(!ret, "T2.7: delta is string (not object) returns false");
|
||||
}
|
||||
|
||||
{
|
||||
std::string token;
|
||||
const char* json =
|
||||
"{\"type\":\"content_block_delta\","
|
||||
"\"delta\":{\"no_type_here\":\"x\"}}";
|
||||
bool ret = parse_sse_data(json, token, nullptr);
|
||||
CHECK(!ret, "T2.8: delta without 'type' field returns false");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Test Block 3: parse_sse_data — message_stop / ignored types
|
||||
// ================================================================
|
||||
std::cout << "\n--- Block 3: parse_sse_data message_stop / ignored types ---\n";
|
||||
|
||||
{
|
||||
std::string token = "SHOULD_BE_CLEARED";
|
||||
const char* json = "{\"type\":\"message_stop\"}";
|
||||
bool ret = parse_sse_data(json, token, nullptr);
|
||||
CHECK(ret, "T3.1: message_stop returns true (stream end)");
|
||||
CHECK(token.empty(), "T3.2: message_stop clears token");
|
||||
}
|
||||
|
||||
{
|
||||
std::string token;
|
||||
const char* json = "{\"type\":\"message_start\",\"message\":{}}";
|
||||
bool ret = parse_sse_data(json, token, nullptr);
|
||||
CHECK(!ret, "T3.3: message_start returns false (ignored)");
|
||||
}
|
||||
|
||||
{
|
||||
std::string token;
|
||||
const char* json = "{\"type\":\"content_block_start\"}";
|
||||
bool ret = parse_sse_data(json, token, nullptr);
|
||||
CHECK(!ret, "T3.4: content_block_start returns false (ignored)");
|
||||
}
|
||||
|
||||
{
|
||||
std::string token;
|
||||
const char* json = "{\"type\":\"content_block_stop\"}";
|
||||
bool ret = parse_sse_data(json, token, nullptr);
|
||||
CHECK(!ret, "T3.5: content_block_stop returns false (ignored)");
|
||||
}
|
||||
|
||||
{
|
||||
std::string token;
|
||||
const char* json = "{\"type\":\"ping\"}";
|
||||
bool ret = parse_sse_data(json, token, nullptr);
|
||||
CHECK(!ret, "T3.6: ping returns false (ignored)");
|
||||
}
|
||||
|
||||
{
|
||||
std::string token;
|
||||
const char* json = "{\"type\":\"message_delta\"}";
|
||||
bool ret = parse_sse_data(json, token, nullptr);
|
||||
CHECK(!ret, "T3.7: message_delta returns false (ignored)");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Test Block 4: parse_sse_data — deeply nested / edge structures
|
||||
// ================================================================
|
||||
std::cout << "\n--- Block 4: parse_sse_data deep/edge structures ---\n";
|
||||
|
||||
{
|
||||
// Unrecognized event type should just be ignored
|
||||
std::string token;
|
||||
const char* json = "{\"type\":\"some_unknown_future_type\"}";
|
||||
bool ret = parse_sse_data(json, token, nullptr);
|
||||
CHECK(!ret, "T4.1: unknown type returns false (ignored)");
|
||||
}
|
||||
|
||||
{
|
||||
// text_delta with unicode content (Japanese)
|
||||
std::string token;
|
||||
const char* json =
|
||||
"{\"type\":\"content_block_delta\","
|
||||
"\"delta\":{\"type\":\"text_delta\","
|
||||
"\"text\":\"\\u3053\\u3093\\u306b\\u3061\\u306f\"}}";
|
||||
bool ret = parse_sse_data(json, token, nullptr);
|
||||
CHECK(ret, "T4.2: unicode text_delta returns true");
|
||||
CHECK(token == "\xe3\x81\x93\xe3\x82\x93\xe3\x81\xab"
|
||||
"\xe3\x81\xa1\xe3\x81\xaf",
|
||||
"T4.3: unicode token decoded correctly"); // こんにちは
|
||||
}
|
||||
|
||||
{
|
||||
// Realistic Anthropic SSE chunk (content_block_delta + text_delta)
|
||||
std::string token;
|
||||
const char* json =
|
||||
"{\"type\":\"content_block_delta\","
|
||||
"\"index\":0,"
|
||||
"\"delta\":{\"type\":\"text_delta\",\"text\":\" I'm\"}}";
|
||||
bool ret = parse_sse_data(json, token, nullptr);
|
||||
CHECK(ret, "T4.4: realistic SSE chunk returns true");
|
||||
CHECK(token == " I'm", "T4.5: token ' I'm' correct");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Test Block 5: build_request_json — basic cases
|
||||
// ================================================================
|
||||
setup_config();
|
||||
std::cout << "\n--- Block 5: build_request_json basic ---\n";
|
||||
|
||||
{
|
||||
// Single user input, no history, stream=false
|
||||
std::string json = build_request_json(nullptr, 0, "Hello", "", false);
|
||||
CHECK(!json.empty(), "T5.1: non-empty JSON produced");
|
||||
CHECK(json.find("\"messages\"") != std::string::npos,
|
||||
"T5.2: contains 'messages' array");
|
||||
CHECK(json.find("\"user\"") != std::string::npos,
|
||||
"T5.3: contains 'user' role");
|
||||
CHECK(json.find("\"Hello\"") != std::string::npos,
|
||||
"T5.4: contains user input text");
|
||||
CHECK(json.find("\"stream\":false") != std::string::npos,
|
||||
"T5.5: stream=false present");
|
||||
CHECK(json.find("\"model\":\"claude-sonnet-4-20250514\"")
|
||||
!= std::string::npos,
|
||||
"T5.6: model field present");
|
||||
CHECK(json.find("\"max_tokens\":4096") != std::string::npos,
|
||||
"T5.7: max_tokens field present");
|
||||
}
|
||||
|
||||
{
|
||||
// With system message in history
|
||||
dstalk_message_t msgs[1] = {
|
||||
{"system", "You are a helpful assistant", nullptr, nullptr}
|
||||
};
|
||||
std::string json = build_request_json(msgs, 1, "Hello", "", false);
|
||||
CHECK(json.find("\"system\"") != std::string::npos,
|
||||
"T5.8: system prompt extracted to 'system' field");
|
||||
CHECK(json.find("You are a helpful assistant") != std::string::npos,
|
||||
"T5.9: system prompt content present");
|
||||
// messages should NOT contain the system role
|
||||
// (since system messages are stripped from messages[] and put in system field)
|
||||
// Actually, the code puts non-system into msgs. Let me check if system is in messages...
|
||||
// The loop skips system: `if (m.role && strcmp(m.role, "system")==0) { ... continue; }`
|
||||
// So system should NOT be in the messages array.
|
||||
CHECK(json.find("\"role\":\"system\"") == std::string::npos,
|
||||
"T5.10: system role NOT in messages array");
|
||||
}
|
||||
|
||||
{
|
||||
// With user+assistant history
|
||||
dstalk_message_t msgs[2] = {
|
||||
{"user", "What is 2+2?", nullptr, nullptr},
|
||||
{"assistant", "It is 4.", nullptr, nullptr}
|
||||
};
|
||||
std::string json = build_request_json(msgs, 2, "Thanks!", "", false);
|
||||
CHECK(json.find("\"role\":\"user\"") != std::string::npos,
|
||||
"T5.11: user role present in messages");
|
||||
CHECK(json.find("\"role\":\"assistant\"") != std::string::npos,
|
||||
"T5.12: assistant role present in messages");
|
||||
CHECK(json.find("Thanks!") != std::string::npos,
|
||||
"T5.13: current user input present");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Test Block 6: build_request_json — edge cases
|
||||
// ================================================================
|
||||
std::cout << "\n--- Block 6: build_request_json edge cases ---\n";
|
||||
|
||||
{
|
||||
// Empty user input
|
||||
std::string json = build_request_json(nullptr, 0, "", "", false);
|
||||
CHECK(!json.empty(), "T6.1: empty user input produces valid JSON");
|
||||
CHECK(json.find("\"role\":\"user\"") != std::string::npos,
|
||||
"T6.2: user role still present with empty content");
|
||||
CHECK(json.find("\"content\":\"\"") != std::string::npos,
|
||||
"T6.3: empty content string present");
|
||||
// The user input IS added even if empty (line 109-112: no check for empty)
|
||||
}
|
||||
|
||||
{
|
||||
// Stream=true
|
||||
std::string json = build_request_json(nullptr, 0, "Hi", "", true);
|
||||
CHECK(json.find("\"stream\":true") != std::string::npos,
|
||||
"T6.4: stream=true present");
|
||||
}
|
||||
|
||||
{
|
||||
// Temperature in valid range -> should be included
|
||||
g_cfg.temperature = 1.0;
|
||||
std::string json = build_request_json(nullptr, 0, "Hi", "", false);
|
||||
CHECK(json.find("\"temperature\"") != std::string::npos,
|
||||
"T6.5: temperature=1.0 included (boundary valid)");
|
||||
g_cfg.temperature = 0.7; // reset
|
||||
}
|
||||
|
||||
{
|
||||
// Temperature out of range -> should NOT be included
|
||||
g_cfg.temperature = 1.5;
|
||||
std::string json = build_request_json(nullptr, 0, "Hi", "", false);
|
||||
CHECK(json.find("\"temperature\"") == std::string::npos,
|
||||
"T6.6: temperature=1.5 (>1.0) omitted");
|
||||
g_cfg.temperature = -0.5;
|
||||
json = build_request_json(nullptr, 0, "Hi", "", false);
|
||||
CHECK(json.find("\"temperature\"") == std::string::npos,
|
||||
"T6.7: temperature=-0.5 (<0.0) omitted");
|
||||
g_cfg.temperature = 0.7; // reset
|
||||
}
|
||||
|
||||
{
|
||||
// History with null role (should default to "")
|
||||
dstalk_message_t msgs[1] = {
|
||||
{nullptr, "some content", nullptr, nullptr}
|
||||
};
|
||||
std::string json = build_request_json(msgs, 1, "Hi", "", false);
|
||||
CHECK(!json.empty(), "T6.8: null role produces valid JSON (no crash)");
|
||||
}
|
||||
|
||||
{
|
||||
// History with null content
|
||||
dstalk_message_t msgs[1] = {
|
||||
{"user", nullptr, nullptr, nullptr}
|
||||
};
|
||||
std::string json = build_request_json(msgs, 1, "Hi", "", false);
|
||||
CHECK(!json.empty(), "T6.9: null content produces valid JSON (no crash)");
|
||||
}
|
||||
|
||||
{
|
||||
// Very long message (>2000 chars) — validate no truncation / crash
|
||||
std::string long_input(5000, 'A');
|
||||
std::string json = build_request_json(nullptr, 0, long_input, "", false);
|
||||
CHECK(!json.empty(), "T6.10: 5000-char input produces valid JSON");
|
||||
CHECK(json.length() > 5000, "T6.11: JSON longer than input (wraps content)");
|
||||
}
|
||||
|
||||
{
|
||||
// Multiple system messages concatenated
|
||||
dstalk_message_t msgs[2] = {
|
||||
{"system", "Rule 1: be polite", nullptr, nullptr},
|
||||
{"system", "Rule 2: be concise", nullptr, nullptr}
|
||||
};
|
||||
std::string json = build_request_json(msgs, 2, "Hello", "", false);
|
||||
CHECK(json.find("be polite") != std::string::npos,
|
||||
"T6.12: first system prompt present");
|
||||
CHECK(json.find("be concise") != std::string::npos,
|
||||
"T6.13: second system prompt present (concatenated)");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Test Block 7: build_headers_json
|
||||
// ================================================================
|
||||
std::cout << "\n--- Block 7: build_headers_json ---\n";
|
||||
|
||||
{
|
||||
std::string headers = build_headers_json();
|
||||
CHECK(headers.find("x-api-key") != std::string::npos,
|
||||
"T7.1: contains x-api-key header");
|
||||
CHECK(headers.find(g_cfg.api_key) != std::string::npos,
|
||||
"T7.2: contains correct API key value");
|
||||
CHECK(headers.find("anthropic-version") != std::string::npos,
|
||||
"T7.3: contains anthropic-version header");
|
||||
CHECK(headers.find("2023-06-01") != std::string::npos,
|
||||
"T7.4: anthropic-version is 2023-06-01");
|
||||
}
|
||||
|
||||
{
|
||||
// With empty API key
|
||||
std::string saved = g_cfg.api_key;
|
||||
g_cfg.api_key = "";
|
||||
std::string headers = build_headers_json();
|
||||
CHECK(headers.find("x-api-key") != std::string::npos,
|
||||
"T7.5: x-api-key present even with empty key");
|
||||
CHECK(headers.find("\"x-api-key\":\"\"") != std::string::npos,
|
||||
"T7.6: empty API key serialized as empty string");
|
||||
g_cfg.api_key = saved;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Test Block 8: extract_host_port
|
||||
// ================================================================
|
||||
std::cout << "\n--- Block 8: extract_host_port ---\n";
|
||||
|
||||
{
|
||||
std::string scheme, host, port, target;
|
||||
bool ret = extract_host_port(
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
scheme, host, port, target);
|
||||
CHECK(ret, "T8.1: valid HTTPS URL returns true");
|
||||
CHECK(scheme == "https", "T8.2: scheme is 'https'");
|
||||
CHECK(host == "api.anthropic.com", "T8.3: host extracted correctly");
|
||||
CHECK(port == "443", "T8.4: default HTTPS port 443");
|
||||
CHECK(target == "/v1/messages", "T8.5: target path extracted");
|
||||
}
|
||||
|
||||
{
|
||||
std::string scheme, host, port, target;
|
||||
bool ret = extract_host_port(
|
||||
"http://localhost:8080/chat",
|
||||
scheme, host, port, target);
|
||||
CHECK(ret, "T8.6: HTTP URL with explicit port returns true");
|
||||
CHECK(scheme == "http", "T8.7: scheme is 'http'");
|
||||
CHECK(host == "localhost", "T8.8: host is 'localhost'");
|
||||
CHECK(port == "8080", "T8.9: explicit port 8080");
|
||||
CHECK(target == "/chat", "T8.10: target path extracted");
|
||||
}
|
||||
|
||||
{
|
||||
std::string scheme, host, port, target;
|
||||
bool ret = extract_host_port(
|
||||
"https://example.com",
|
||||
scheme, host, port, target);
|
||||
CHECK(ret, "T8.11: URL without path returns true");
|
||||
CHECK(scheme == "https", "T8.12: scheme is 'https'");
|
||||
CHECK(host == "example.com", "T8.13: host extracted");
|
||||
CHECK(port == "443", "T8.14: default HTTPS port");
|
||||
CHECK(target == "/", "T8.15: target defaults to '/'");
|
||||
}
|
||||
|
||||
{
|
||||
std::string scheme, host, port, target;
|
||||
bool ret = extract_host_port(
|
||||
"http://localhost",
|
||||
scheme, host, port, target);
|
||||
CHECK(ret, "T8.16: HTTP URL returns true");
|
||||
CHECK(port == "80", "T8.17: default HTTP port 80");
|
||||
}
|
||||
|
||||
{
|
||||
std::string scheme, host, port, target;
|
||||
bool ret = extract_host_port(
|
||||
"no-scheme-url.com/path",
|
||||
scheme, host, port, target);
|
||||
CHECK(!ret, "T8.18: URL without scheme returns false");
|
||||
}
|
||||
|
||||
{
|
||||
std::string scheme, host, port, target;
|
||||
bool ret = extract_host_port(
|
||||
"https://127.0.0.1:9090/v1/chat/completions",
|
||||
scheme, host, port, target);
|
||||
CHECK(ret, "T8.19: IP address with port returns true");
|
||||
CHECK(host == "127.0.0.1", "T8.20: IP host extracted");
|
||||
CHECK(port == "9090", "T8.21: port 9090 extracted");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Test Block 9: secure_zero
|
||||
// ================================================================
|
||||
std::cout << "\n--- Block 9: secure_zero ---\n";
|
||||
|
||||
{
|
||||
char buf[16];
|
||||
memset(buf, 0xFF, sizeof(buf));
|
||||
secure_zero(buf, sizeof(buf));
|
||||
bool all_zero = true;
|
||||
for (int i = 0; i < 16; ++i) {
|
||||
if (buf[i] != 0) { all_zero = false; break; }
|
||||
}
|
||||
CHECK(all_zero, "T9.1: secure_zero zeros entire buffer");
|
||||
}
|
||||
|
||||
{
|
||||
// Zero-length should not crash
|
||||
char buf[4] = {1,2,3,4};
|
||||
secure_zero(buf, 0);
|
||||
CHECK(buf[0] == 1 && buf[3] == 4,
|
||||
"T9.2: secure_zero(0) is a no-op (buffer unchanged)");
|
||||
}
|
||||
|
||||
{
|
||||
// Null pointer + zero length = no-op
|
||||
secure_zero(nullptr, 0);
|
||||
CHECK(true, "T9.3: secure_zero(nullptr, 0) does not crash");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Test Block 10: my_free_result — null safety
|
||||
// ================================================================
|
||||
std::cout << "\n--- Block 10: my_free_result null safety ---\n";
|
||||
|
||||
{
|
||||
// g_host is nullptr, so free_result should early-return
|
||||
my_free_result(nullptr);
|
||||
CHECK(true, "T10.1: free_result(nullptr) does not crash (null host)");
|
||||
}
|
||||
|
||||
{
|
||||
dstalk_chat_result_t r = {};
|
||||
r.ok = 1;
|
||||
r.content = nullptr;
|
||||
r.error = nullptr;
|
||||
r.tool_calls_json = nullptr;
|
||||
my_free_result(&r);
|
||||
CHECK(true, "T10.2: free_result with all-null fields does not crash");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Test Block 11: my_configure — null host safety
|
||||
// ================================================================
|
||||
std::cout << "\n--- Block 11: my_configure null host safety ---\n";
|
||||
|
||||
{
|
||||
// g_host is nullptr, configure should still return 0 (log skipped)
|
||||
int ret = my_configure(
|
||||
"anthropic", "https://api.anthropic.com",
|
||||
"sk-key", "claude-sonnet", 2048, 0.5);
|
||||
CHECK(ret == 0, "T11.1: my_configure returns 0 with null host");
|
||||
CHECK(g_cfg.provider == "anthropic", "T11.2: provider stored");
|
||||
CHECK(g_cfg.max_tokens == 2048, "T11.3: max_tokens stored");
|
||||
CHECK(g_cfg.temperature == 0.5, "T11.4: temperature stored");
|
||||
}
|
||||
|
||||
{
|
||||
// Null string params — should not crash
|
||||
int ret = my_configure(nullptr, nullptr, nullptr, nullptr, 4096, 0.7);
|
||||
CHECK(ret == 0, "T11.5: my_configure with all-null strings returns 0");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Summary
|
||||
// ================================================================
|
||||
std::cout << "\n";
|
||||
if (g_failures == 0) {
|
||||
std::cout << "=== All anthropic plugin tests passed ===\n";
|
||||
return 0;
|
||||
} else {
|
||||
std::cerr << "=== " << g_failures << " test(s) FAILED ===\n";
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
669
tests/deepseek_plugin_test.cpp
Normal file
669
tests/deepseek_plugin_test.cpp
Normal file
@@ -0,0 +1,669 @@
|
||||
// ============================================================================
|
||||
// deepseek_plugin_test.cpp — DeepSeek AI 插件单元测试
|
||||
// W21.6 (qa-wang): 覆盖 SSE 解析 / [DONE] 匹配 / JSON 请求构建 / tool_calls
|
||||
// 通过 #include plugin source 访问 file-scope static 函数
|
||||
// ============================================================================
|
||||
#define BOOST_JSON_HEADER_ONLY
|
||||
#define BOOST_ALL_NO_LIB
|
||||
#include "../plugins/deepseek/src/deepseek_plugin.cpp"
|
||||
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond, msg) do { \
|
||||
if (cond) { \
|
||||
std::cout << "[OK] " << (msg) << "\n"; \
|
||||
} else { \
|
||||
std::cerr << "[FAIL] " << (msg) << "\n"; \
|
||||
g_failures++; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
// Test helper: populate g_cfg for build functions
|
||||
static void setup_config() {
|
||||
g_cfg.provider = "deepseek";
|
||||
g_cfg.base_url = "https://api.deepseek.com/v1";
|
||||
g_cfg.api_key = "sk-ds-test-key-67890";
|
||||
g_cfg.model = "deepseek-v4-pro";
|
||||
g_cfg.max_tokens = 4096;
|
||||
g_cfg.temperature = 0.7;
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
// ================================================================
|
||||
// Test Block 1: parse_sse_line — invalid/malformed inputs
|
||||
// ================================================================
|
||||
std::cout << "\n--- Block 1: parse_sse_line invalid/malformed ---\n";
|
||||
|
||||
{
|
||||
std::string token;
|
||||
bool ret = parse_sse_line("", token, nullptr);
|
||||
CHECK(!ret, "T1.1: empty line returns false");
|
||||
}
|
||||
|
||||
{
|
||||
std::string token;
|
||||
bool ret = parse_sse_line("not a data line", token, nullptr);
|
||||
CHECK(!ret, "T1.2: non-'data:' prefix returns false");
|
||||
}
|
||||
|
||||
{
|
||||
std::string token;
|
||||
bool ret = parse_sse_line("event: message", token, nullptr);
|
||||
CHECK(!ret, "T1.3: 'event:' line returns false");
|
||||
}
|
||||
|
||||
{
|
||||
// "data:" without space — rfind("data: ", 0) should fail
|
||||
std::string token;
|
||||
bool ret = parse_sse_line("data:{\"x\":1}", token, nullptr);
|
||||
CHECK(!ret, "T1.4: 'data:' without trailing space returns false (rfind mismatch)");
|
||||
}
|
||||
|
||||
{
|
||||
// "data: " followed by invalid JSON
|
||||
std::string token;
|
||||
bool ret = parse_sse_line("data: not valid json!!!", token, nullptr);
|
||||
CHECK(!ret, "T1.5: 'data: ' + invalid JSON returns false (no crash)");
|
||||
}
|
||||
|
||||
{
|
||||
// "data: " followed by binary garbage
|
||||
std::string token;
|
||||
bool ret = parse_sse_line("data: \x00\x01\xFF\xFE", token, nullptr);
|
||||
CHECK(!ret, "T1.6: 'data: ' + binary garbage returns false (no crash)");
|
||||
}
|
||||
|
||||
{
|
||||
// Empty data after "data: "
|
||||
std::string token;
|
||||
bool ret = parse_sse_line("data: ", token, nullptr);
|
||||
CHECK(!ret, "T1.7: 'data: ' with empty payload returns false");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Test Block 2: parse_sse_line — [DONE] sentinel
|
||||
// ================================================================
|
||||
std::cout << "\n--- Block 2: parse_sse_line [DONE] sentinel ---\n";
|
||||
|
||||
{
|
||||
std::string token = "SHOULD_BE_CLEARED";
|
||||
bool ret = parse_sse_line("data: [DONE]", token, nullptr);
|
||||
CHECK(ret, "T2.1: 'data: [DONE]' returns true (stream end)");
|
||||
CHECK(token.empty(), "T2.2: [DONE] clears token");
|
||||
}
|
||||
|
||||
{
|
||||
// [DONE] with leading whitespace
|
||||
std::string token;
|
||||
bool ret = parse_sse_line("data: [DONE]", token, nullptr);
|
||||
CHECK(ret, "T2.3: 'data: [DONE]' (leading spaces) returns true");
|
||||
CHECK(token.empty(), "T2.4: whitespace-trimmed [DONE] clears token");
|
||||
}
|
||||
|
||||
{
|
||||
// [DONE] with trailing whitespace
|
||||
std::string token;
|
||||
bool ret = parse_sse_line("data: [DONE] ", token, nullptr);
|
||||
CHECK(ret, "T2.5: 'data: [DONE] ' (trailing spaces) returns true");
|
||||
CHECK(token.empty(), "T2.6: trailing-whitespace [DONE] clears token");
|
||||
}
|
||||
|
||||
{
|
||||
// [DONE] with tabs and newlines around it
|
||||
std::string token;
|
||||
bool ret = parse_sse_line("data: \t [DONE] \t\r\n", token, nullptr);
|
||||
CHECK(ret, "T2.7: '[DONE]' with mixed whitespace returns true");
|
||||
CHECK(token.empty(), "T2.8: mixed-whitespace [DONE] clears token");
|
||||
}
|
||||
|
||||
{
|
||||
// [DONE] without spaces — exact match
|
||||
std::string token;
|
||||
bool ret = parse_sse_line("data: [DONE]", token, nullptr);
|
||||
CHECK(ret, "T2.9: '[DONE]' exact match returns true");
|
||||
}
|
||||
|
||||
{
|
||||
// "[done]" lowercase — should NOT match (case-sensitive)
|
||||
std::string token;
|
||||
bool ret = parse_sse_line("data: [done]", token, nullptr);
|
||||
CHECK(!ret, "T2.10: '[done]' lowercase NOT treated as DONE (case-sensitive)");
|
||||
}
|
||||
|
||||
{
|
||||
// "[DONE" without closing bracket
|
||||
std::string token;
|
||||
bool ret = parse_sse_line("data: [DONE", token, nullptr);
|
||||
CHECK(!ret, "T2.11: '[DONE' (no closing bracket) not treated as DONE");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Test Block 3: parse_sse_line — content delta
|
||||
// ================================================================
|
||||
std::cout << "\n--- Block 3: parse_sse_line content delta ---\n";
|
||||
|
||||
{
|
||||
std::string token;
|
||||
const char* json =
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"},"
|
||||
"\"index\":0}]}";
|
||||
bool ret = parse_sse_line(json, token, nullptr);
|
||||
CHECK(ret, "T3.1: delta with content 'Hello' returns true");
|
||||
CHECK(token == "Hello", "T3.2: token equals 'Hello'");
|
||||
}
|
||||
|
||||
{
|
||||
std::string token;
|
||||
const char* json =
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"\"},\"index\":0}]}";
|
||||
bool ret = parse_sse_line(json, token, nullptr);
|
||||
CHECK(ret, "T3.3: delta with empty content returns true");
|
||||
CHECK(token.empty(), "T3.4: empty content token is empty");
|
||||
}
|
||||
|
||||
{
|
||||
// Delta with no content field
|
||||
std::string token;
|
||||
const char* json =
|
||||
"data: {\"choices\":[{\"delta\":{},\"index\":0}]}";
|
||||
bool ret = parse_sse_line(json, token, nullptr);
|
||||
CHECK(!ret, "T3.5: delta without 'content' field returns false");
|
||||
}
|
||||
|
||||
{
|
||||
// Empty choices array
|
||||
std::string token;
|
||||
const char* json =
|
||||
"data: {\"choices\":[]}";
|
||||
bool ret = parse_sse_line(json, token, nullptr);
|
||||
CHECK(!ret, "T3.6: empty choices array returns false");
|
||||
}
|
||||
|
||||
{
|
||||
// Single character token (typical streaming)
|
||||
std::string token;
|
||||
const char* json =
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"H\"},\"index\":0}]}";
|
||||
bool ret = parse_sse_line(json, token, nullptr);
|
||||
CHECK(ret, "T3.7: single-char delta returns true");
|
||||
CHECK(token == "H", "T3.8: single-char token correct");
|
||||
}
|
||||
|
||||
{
|
||||
// Multi-byte UTF-8 content (emoji) in delta
|
||||
std::string token;
|
||||
const char* json =
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"\\uD83D\\uDE00\"},"
|
||||
"\"index\":0}]}";
|
||||
bool ret = parse_sse_line(json, token, nullptr);
|
||||
CHECK(ret, "T3.9: emoji delta returns true");
|
||||
// U+1F600 in UTF-8: F0 9F 98 80
|
||||
std::string emoji = "\xF0\x9F\x98\x80";
|
||||
CHECK(token == emoji, "T3.10: emoji token decoded correctly (U+1F600)");
|
||||
}
|
||||
|
||||
{
|
||||
// Malformed JSON structure — no "delta" key
|
||||
std::string token;
|
||||
const char* json =
|
||||
"data: {\"choices\":[{\"no_delta\":{},\"index\":0}]}";
|
||||
bool ret = parse_sse_line(json, token, nullptr);
|
||||
CHECK(!ret, "T3.11: choice without 'delta' key returns false");
|
||||
}
|
||||
|
||||
{
|
||||
// Realistic DeepSeek streaming chunk (with finish_reason)
|
||||
std::string token;
|
||||
const char* json =
|
||||
"data: {\"id\":\"chatcmpl-xxx\","
|
||||
"\"object\":\"chat.completion.chunk\","
|
||||
"\"created\":1712345678,"
|
||||
"\"model\":\"deepseek-v4-pro\","
|
||||
"\"choices\":[{\"index\":0,"
|
||||
"\"delta\":{\"content\":\" World\"},"
|
||||
"\"finish_reason\":null}]}";
|
||||
bool ret = parse_sse_line(json, token, nullptr);
|
||||
CHECK(ret, "T3.12: realistic DeepSeek chunk returns true");
|
||||
CHECK(token == " World", "T3.13: realistic chunk token correct");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Test Block 4: parse_sse_line — tool_calls delta
|
||||
// ================================================================
|
||||
std::cout << "\n--- Block 4: parse_sse_line tool_calls delta ---\n";
|
||||
|
||||
{
|
||||
// tool_calls chunk with id + function name (first chunk)
|
||||
StreamContext ctx = {};
|
||||
std::string token;
|
||||
const char* json =
|
||||
"data: {\"choices\":[{\"index\":0,"
|
||||
"\"delta\":{\"tool_calls\":[{\"index\":0,"
|
||||
"\"id\":\"call_abc123\","
|
||||
"\"type\":\"function\","
|
||||
"\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]}}]}";
|
||||
bool ret = parse_sse_line(json, token, &ctx);
|
||||
CHECK(!ret, "T4.1: tool_calls first chunk returns false (no content token)");
|
||||
CHECK(ctx.tool_calls.size() >= 1, "T4.2: tool_calls accumulated in ctx");
|
||||
if (ctx.tool_calls.size() >= 1) {
|
||||
CHECK(ctx.tool_calls[0].index == 0, "T4.3: tool_call index=0");
|
||||
CHECK(ctx.tool_calls[0].id == "call_abc123", "T4.4: tool_call id stored");
|
||||
CHECK(ctx.tool_calls[0].name == "get_weather", "T4.5: tool_call name stored");
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
// tool_calls arguments chunk (second chunk, same index)
|
||||
StreamContext ctx;
|
||||
// First, set up the initial state
|
||||
ctx.tool_calls.push_back({0, "call_abc123", "get_weather", ""});
|
||||
|
||||
std::string token;
|
||||
const char* json =
|
||||
"data: {\"choices\":[{\"index\":0,"
|
||||
"\"delta\":{\"tool_calls\":[{\"index\":0,"
|
||||
"\"function\":{\"arguments\":\"{\\\"city\\\":\\\"\"}}]}}]}";
|
||||
bool ret = parse_sse_line(json, token, &ctx);
|
||||
CHECK(!ret, "T4.6: tool_calls arguments chunk returns false");
|
||||
if (ctx.tool_calls.size() >= 1) {
|
||||
CHECK(ctx.tool_calls[0].arguments == "{\"city\":\"",
|
||||
"T4.7: arguments accumulated (first fragment)");
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
// tool_calls final arguments chunk
|
||||
StreamContext ctx;
|
||||
ctx.tool_calls.push_back({0, "call_abc123", "get_weather", "{\"city\":\""});
|
||||
|
||||
std::string token;
|
||||
const char* json =
|
||||
"data: {\"choices\":[{\"index\":0,"
|
||||
"\"delta\":{\"tool_calls\":[{\"index\":0,"
|
||||
"\"function\":{\"arguments\":\"Beijing\\\"}\"}}]}}]}";
|
||||
bool ret = parse_sse_line(json, token, &ctx);
|
||||
CHECK(!ret, "T4.8: tool_calls final chunk returns false");
|
||||
if (ctx.tool_calls.size() >= 1) {
|
||||
CHECK(ctx.tool_calls[0].arguments == "{\"city\":\"Beijing\"}",
|
||||
"T4.9: full arguments accumulated correctly");
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
// tool_calls with null ctx — should skip tool_calls processing
|
||||
std::string token;
|
||||
const char* json =
|
||||
"data: {\"choices\":[{\"index\":0,"
|
||||
"\"delta\":{\"tool_calls\":[{\"index\":0,"
|
||||
"\"function\":{\"arguments\":\"{}\"}}]}}]}";
|
||||
bool ret = parse_sse_line(json, token, nullptr);
|
||||
CHECK(!ret, "T4.10: tool_calls with null ctx returns false (no crash)");
|
||||
}
|
||||
|
||||
{
|
||||
// Multiple tool_calls in single chunk (unusual but valid)
|
||||
StreamContext ctx;
|
||||
std::string token;
|
||||
const char* json =
|
||||
"data: {\"choices\":[{\"index\":0,"
|
||||
"\"delta\":{\"tool_calls\":["
|
||||
"{\"index\":0,\"function\":{\"arguments\":\"a\"}},"
|
||||
"{\"index\":1,\"function\":{\"arguments\":\"b\"}}"
|
||||
"]}}]}";
|
||||
bool ret = parse_sse_line(json, token, &ctx);
|
||||
CHECK(!ret, "T4.11: multi-tool_call chunk returns false");
|
||||
CHECK(ctx.tool_calls.size() >= 2, "T4.12: both tool_calls accumulated");
|
||||
if (ctx.tool_calls.size() >= 2) {
|
||||
CHECK(ctx.tool_calls[0].index == 0, "T4.13: first tool_call index=0");
|
||||
CHECK(ctx.tool_calls[1].index == 1, "T4.14: second tool_call index=1");
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Test Block 5: build_request_json — basic cases
|
||||
// ================================================================
|
||||
setup_config();
|
||||
std::cout << "\n--- Block 5: build_request_json basic ---\n";
|
||||
|
||||
{
|
||||
std::string json = build_request_json(
|
||||
nullptr, 0, "Hello", "", false);
|
||||
CHECK(!json.empty(), "T5.1: non-empty JSON produced");
|
||||
CHECK(json.find("\"messages\"") != std::string::npos,
|
||||
"T5.2: contains 'messages' array");
|
||||
CHECK(json.find("\"user\"") != std::string::npos,
|
||||
"T5.3: contains 'user' role");
|
||||
CHECK(json.find("\"Hello\"") != std::string::npos,
|
||||
"T5.4: contains user input");
|
||||
CHECK(json.find("\"stream\":false") != std::string::npos,
|
||||
"T5.5: stream=false present");
|
||||
CHECK(json.find("\"model\":\"deepseek-v4-pro\"")
|
||||
!= std::string::npos,
|
||||
"T5.6: model field present");
|
||||
CHECK(json.find("\"max_tokens\":4096") != std::string::npos,
|
||||
"T5.7: max_tokens present");
|
||||
CHECK(json.find("\"temperature\"") != std::string::npos,
|
||||
"T5.8: temperature field present (always included in DeepSeek)");
|
||||
}
|
||||
|
||||
{
|
||||
// With user+assistant history
|
||||
dstalk_message_t msgs[2] = {
|
||||
{"user", "What is 2+2?", nullptr, nullptr},
|
||||
{"assistant", "It is 4.", nullptr, nullptr}
|
||||
};
|
||||
std::string json = build_request_json(
|
||||
msgs, 2, "Thanks!", "", false);
|
||||
CHECK(json.find("\"role\":\"user\"") != std::string::npos,
|
||||
"T5.9: user role present");
|
||||
CHECK(json.find("\"role\":\"assistant\"") != std::string::npos,
|
||||
"T5.10: assistant role present");
|
||||
CHECK(json.find("Thanks!") != std::string::npos,
|
||||
"T5.11: current user input present");
|
||||
}
|
||||
|
||||
{
|
||||
// Stream=true
|
||||
std::string json = build_request_json(
|
||||
nullptr, 0, "Hi", "", true);
|
||||
CHECK(json.find("\"stream\":true") != std::string::npos,
|
||||
"T5.12: stream=true present");
|
||||
}
|
||||
|
||||
{
|
||||
// Empty user input — no user message appended
|
||||
std::string json = build_request_json(
|
||||
nullptr, 0, "", "", false);
|
||||
CHECK(!json.empty(), "T5.13: empty user input produces valid JSON");
|
||||
// DeepSeek's build_request_json checks `if (!user_input.empty())` before adding
|
||||
// So there should be no user message for empty input
|
||||
CHECK(json.find("\"role\":\"user\"") == std::string::npos,
|
||||
"T5.14: empty user input NOT added to messages (DeepSeek guard)");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Test Block 6: build_request_json — tools / edge cases
|
||||
// ================================================================
|
||||
std::cout << "\n--- Block 6: build_request_json tools / edges ---\n";
|
||||
|
||||
{
|
||||
// With tools_json
|
||||
std::string tools = "[{\"type\":\"function\","
|
||||
"\"function\":{\"name\":\"get_weather\","
|
||||
"\"description\":\"Get current weather\","
|
||||
"\"parameters\":{\"type\":\"object\","
|
||||
"\"properties\":{\"city\":{\"type\":\"string\"}},"
|
||||
"\"required\":[\"city\"]}}}]";
|
||||
std::string json = build_request_json(
|
||||
nullptr, 0, "Weather in Beijing?", tools, false);
|
||||
CHECK(json.find("\"tools\"") != std::string::npos,
|
||||
"T6.1: 'tools' field present when tools_json provided");
|
||||
CHECK(json.find("get_weather") != std::string::npos,
|
||||
"T6.2: tool name present in serialized JSON");
|
||||
}
|
||||
|
||||
{
|
||||
// Empty tools_json — no tools field
|
||||
std::string json = build_request_json(
|
||||
nullptr, 0, "Hello", "", false);
|
||||
CHECK(json.find("\"tools\"") == std::string::npos,
|
||||
"T6.3: no 'tools' field when tools_json is empty");
|
||||
}
|
||||
|
||||
{
|
||||
// Malformed tools_json — build_request_json calls json::parse()
|
||||
// without try/catch, so it will throw std::exception.
|
||||
// This test verifies that the exception is thrown (rather than crashing).
|
||||
bool threw = false;
|
||||
try {
|
||||
build_request_json(nullptr, 0, "Hello", "NOT JSON", false);
|
||||
} catch (const std::exception&) {
|
||||
threw = true;
|
||||
} catch (...) {
|
||||
threw = true;
|
||||
}
|
||||
CHECK(threw, "T6.4: malformed tools_json throws (expected, not a crash)");
|
||||
}
|
||||
|
||||
{
|
||||
// History with null role
|
||||
dstalk_message_t msgs[1] = {
|
||||
{nullptr, "some content", nullptr, nullptr}
|
||||
};
|
||||
std::string json = build_request_json(msgs, 1, "Hi", "", false);
|
||||
CHECK(!json.empty(), "T6.5: null role produces valid JSON (no crash)");
|
||||
}
|
||||
|
||||
{
|
||||
// History with null content
|
||||
dstalk_message_t msgs[1] = {
|
||||
{"user", nullptr, nullptr, nullptr}
|
||||
};
|
||||
std::string json = build_request_json(msgs, 1, "Hi", "", false);
|
||||
CHECK(!json.empty(), "T6.6: null content produces valid JSON (no crash)");
|
||||
}
|
||||
|
||||
{
|
||||
// Very long message
|
||||
std::string long_input(5000, 'A');
|
||||
std::string json = build_request_json(
|
||||
nullptr, 0, long_input, "", false);
|
||||
CHECK(!json.empty(), "T6.7: 5000-char input produces valid JSON");
|
||||
CHECK(json.length() > 5000, "T6.8: JSON longer than input (wraps content)");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Test Block 7: build_headers_json
|
||||
// ================================================================
|
||||
std::cout << "\n--- Block 7: build_headers_json ---\n";
|
||||
|
||||
{
|
||||
std::string headers = build_headers_json("sk-test-123");
|
||||
CHECK(headers.find("Authorization") != std::string::npos,
|
||||
"T7.1: contains Authorization header");
|
||||
CHECK(headers.find("Bearer sk-test-123") != std::string::npos,
|
||||
"T7.2: contains 'Bearer sk-test-123'");
|
||||
}
|
||||
|
||||
{
|
||||
// Empty API key
|
||||
std::string headers = build_headers_json("");
|
||||
CHECK(headers.find("Authorization") != std::string::npos,
|
||||
"T7.3: Authorization header present with empty key");
|
||||
CHECK(headers.find("Bearer ") != std::string::npos,
|
||||
"T7.4: 'Bearer ' prefix present even with empty key");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Test Block 8: extract_host_port (same logic as anthropic)
|
||||
// ================================================================
|
||||
std::cout << "\n--- Block 8: extract_host_port ---\n";
|
||||
|
||||
{
|
||||
std::string scheme, host, port, target;
|
||||
bool ret = extract_host_port(
|
||||
"https://api.deepseek.com/v1/chat/completions",
|
||||
scheme, host, port, target);
|
||||
CHECK(ret, "T8.1: valid HTTPS URL returns true");
|
||||
CHECK(scheme == "https", "T8.2: scheme is 'https'");
|
||||
CHECK(host == "api.deepseek.com", "T8.3: host extracted");
|
||||
CHECK(port == "443", "T8.4: default HTTPS port 443");
|
||||
CHECK(target == "/v1/chat/completions", "T8.5: target path extracted");
|
||||
}
|
||||
|
||||
{
|
||||
std::string scheme, host, port, target;
|
||||
bool ret = extract_host_port(
|
||||
"http://localhost:11434/api/generate",
|
||||
scheme, host, port, target);
|
||||
CHECK(ret, "T8.6: HTTP URL with port returns true");
|
||||
CHECK(scheme == "http", "T8.7: scheme is 'http'");
|
||||
CHECK(host == "localhost", "T8.8: host is 'localhost'");
|
||||
CHECK(port == "11434", "T8.9: explicit port 11434");
|
||||
}
|
||||
|
||||
{
|
||||
std::string scheme, host, port, target;
|
||||
bool ret = extract_host_port(
|
||||
"no-scheme-url.com",
|
||||
scheme, host, port, target);
|
||||
CHECK(!ret, "T8.10: URL without scheme returns false");
|
||||
}
|
||||
|
||||
{
|
||||
std::string scheme, host, port, target;
|
||||
bool ret = extract_host_port(
|
||||
"https://api.deepseek.com",
|
||||
scheme, host, port, target);
|
||||
CHECK(ret, "T8.11: URL without path returns true");
|
||||
CHECK(target == "/", "T8.12: target defaults to '/'");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Test Block 9: secure_zero
|
||||
// ================================================================
|
||||
std::cout << "\n--- Block 9: secure_zero ---\n";
|
||||
|
||||
{
|
||||
char buf[16];
|
||||
memset(buf, 0xFF, sizeof(buf));
|
||||
secure_zero(buf, sizeof(buf));
|
||||
bool all_zero = true;
|
||||
for (int i = 0; i < 16; ++i) {
|
||||
if (buf[i] != 0) { all_zero = false; break; }
|
||||
}
|
||||
CHECK(all_zero, "T9.1: secure_zero zeros entire buffer");
|
||||
}
|
||||
|
||||
{
|
||||
secure_zero(nullptr, 0);
|
||||
CHECK(true, "T9.2: secure_zero(nullptr, 0) does not crash");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Test Block 10: append_history
|
||||
// ================================================================
|
||||
std::cout << "\n--- Block 10: append_history ---\n";
|
||||
|
||||
{
|
||||
json::array msgs;
|
||||
dstalk_message_t m = {"user", "Hello", nullptr, nullptr};
|
||||
append_history(msgs, &m, 1);
|
||||
CHECK(msgs.size() == 1, "T10.1: one message appended");
|
||||
CHECK(msgs[0].as_object()["role"].as_string() == "user",
|
||||
"T10.2: role preserved");
|
||||
CHECK(msgs[0].as_object()["content"].as_string() == "Hello",
|
||||
"T10.3: content preserved");
|
||||
}
|
||||
|
||||
{
|
||||
// Tool message (should include tool_call_id)
|
||||
json::array msgs;
|
||||
dstalk_message_t m = {"tool", "result data", "call_xyz", nullptr};
|
||||
append_history(msgs, &m, 1);
|
||||
CHECK(msgs.size() == 1, "T10.4: tool message appended");
|
||||
auto obj = msgs[0].as_object();
|
||||
CHECK(obj["role"].as_string() == "tool", "T10.5: tool role preserved");
|
||||
CHECK(obj["tool_call_id"].as_string() == "call_xyz",
|
||||
"T10.6: tool_call_id preserved");
|
||||
CHECK(obj["content"].as_string() == "result data",
|
||||
"T10.7: tool message content preserved");
|
||||
}
|
||||
|
||||
{
|
||||
// Assistant with tool_calls_json
|
||||
json::array msgs;
|
||||
const char* tc_json = "[{\"id\":\"call_1\",\"type\":\"function\","
|
||||
"\"function\":{\"name\":\"get_weather\",\"arguments\":\"{}\"}}]";
|
||||
dstalk_message_t m = {"assistant", "Let me check", nullptr, tc_json};
|
||||
append_history(msgs, &m, 1);
|
||||
CHECK(msgs.size() == 1, "T10.8: assistant with tool_calls appended");
|
||||
auto obj = msgs[0].as_object();
|
||||
CHECK(obj["role"].as_string() == "assistant", "T10.9: assistant role");
|
||||
CHECK(obj["content"].as_string() == "Let me check", "T10.10: content");
|
||||
CHECK(obj.if_contains("tool_calls") != nullptr, "T10.11: tool_calls present");
|
||||
}
|
||||
|
||||
{
|
||||
// Empty history (0 messages)
|
||||
json::array msgs;
|
||||
append_history(msgs, nullptr, 0);
|
||||
CHECK(msgs.size() == 0, "T10.12: empty history produces empty array");
|
||||
}
|
||||
|
||||
{
|
||||
// Multiple messages
|
||||
json::array msgs;
|
||||
dstalk_message_t ms[2] = {
|
||||
{"user", "Q1", nullptr, nullptr},
|
||||
{"assistant", "A1", nullptr, nullptr}
|
||||
};
|
||||
append_history(msgs, ms, 2);
|
||||
CHECK(msgs.size() == 2, "T10.13: two messages appended");
|
||||
}
|
||||
|
||||
{
|
||||
// Null role and null content — default to empty strings
|
||||
json::array msgs;
|
||||
dstalk_message_t m = {nullptr, nullptr, nullptr, nullptr};
|
||||
append_history(msgs, &m, 1);
|
||||
CHECK(msgs.size() == 1, "T10.14: null fields produce valid JSON (no crash)");
|
||||
auto obj = msgs[0].as_object();
|
||||
CHECK(obj["role"].as_string() == "", "T10.15: null role → empty string");
|
||||
CHECK(obj["content"].as_string() == "", "T10.16: null content → empty string");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Test Block 11: my_free_result — null safety
|
||||
// ================================================================
|
||||
std::cout << "\n--- Block 11: my_free_result null safety ---\n";
|
||||
|
||||
{
|
||||
// g_host is nullptr, so free_result should early-return
|
||||
my_free_result(nullptr);
|
||||
CHECK(true, "T11.1: free_result(nullptr) does not crash (null host)");
|
||||
}
|
||||
|
||||
{
|
||||
dstalk_chat_result_t r = {};
|
||||
r.ok = 1;
|
||||
my_free_result(&r);
|
||||
CHECK(true, "T11.2: free_result with zeroed fields does not crash");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Test Block 12: my_configure — null host safety
|
||||
// ================================================================
|
||||
std::cout << "\n--- Block 12: my_configure null host safety ---\n";
|
||||
|
||||
{
|
||||
int ret = my_configure(
|
||||
"deepseek", "https://api.deepseek.com/v1",
|
||||
"sk-key", "deepseek-v4", 2048, 0.5);
|
||||
CHECK(ret == 0, "T12.1: my_configure returns 0 with null host");
|
||||
CHECK(g_cfg.provider == "deepseek", "T12.2: provider stored");
|
||||
CHECK(g_cfg.max_tokens == 2048, "T12.3: max_tokens stored");
|
||||
CHECK(g_cfg.temperature == 0.5, "T12.4: temperature stored");
|
||||
}
|
||||
|
||||
{
|
||||
int ret = my_configure(nullptr, nullptr, nullptr, nullptr, 4096, 1.0);
|
||||
CHECK(ret == 0, "T12.5: my_configure with all-null strings returns 0");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Summary
|
||||
// ================================================================
|
||||
std::cout << "\n";
|
||||
if (g_failures == 0) {
|
||||
std::cout << "=== All deepseek plugin tests passed ===\n";
|
||||
return 0;
|
||||
} else {
|
||||
std::cerr << "=== " << g_failures << " test(s) FAILED ===\n";
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,13 @@ static int g_regression_failures = 0;
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
// ---- W21.5 mock tool handler (qa-xu) ----
|
||||
static int g_mock_tool_called = 0;
|
||||
static char* mock_tool_handler(const char* /*args_json*/) {
|
||||
g_mock_tool_called++;
|
||||
return dstalk_strdup("{\"mock_result\":\"ok\"}");
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
const auto dir = std::filesystem::temp_directory_path() / "dstalk-smoke-test";
|
||||
@@ -560,6 +567,103 @@ int main()
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// W21.5 Tool Calls 边界测试 (qa-xu 徐磊)
|
||||
// 覆盖: null tool_calls_json / 空数组 "[]" / 有效 tool_calls mock 验证
|
||||
// ========================================================================
|
||||
std::cout << "\n--- Tool Calls Boundary Tests (W21.5) ---\n";
|
||||
|
||||
if (tools && session) {
|
||||
// ---- W21.5-1: null tool_calls_json → 正常处理(不崩溃)----
|
||||
{
|
||||
int before = 0;
|
||||
session->history(&before);
|
||||
|
||||
dstalk_message_t msg_null_tc = {
|
||||
"assistant", "content with null tool_calls_json", nullptr, nullptr
|
||||
};
|
||||
session->add(&msg_null_tc);
|
||||
|
||||
int after = 0;
|
||||
const dstalk_message_t* hist = session->history(&after);
|
||||
REGCHECK(after == before + 1,
|
||||
"W21.5-1a: null tool_calls_json add — count +1 (no crash)");
|
||||
if (hist && after > 0) {
|
||||
REGCHECK(hist[after - 1].tool_calls_json == nullptr,
|
||||
"W21.5-1b: null tool_calls_json — retrieved field is nullptr");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- W21.5-2: 空 JSON 数组 "[]" → 正常处理(不崩溃)----
|
||||
{
|
||||
int before = 0;
|
||||
session->history(&before);
|
||||
|
||||
dstalk_message_t msg_empty = {
|
||||
"assistant", "content with empty [] tool_calls", nullptr, "[]"
|
||||
};
|
||||
session->add(&msg_empty);
|
||||
|
||||
int after = 0;
|
||||
const dstalk_message_t* hist = session->history(&after);
|
||||
REGCHECK(after == before + 1,
|
||||
"W21.5-2a: empty [] tool_calls_json add — count +1 (no crash)");
|
||||
if (hist && after > 0) {
|
||||
REGCHECK(hist[after - 1].tool_calls_json != nullptr &&
|
||||
std::strcmp(hist[after - 1].tool_calls_json, "[]") == 0,
|
||||
"W21.5-2b: empty [] tool_calls_json — retrieved as \"[]\"");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- W21.5-3: 有效 tool_calls JSON → 验证 execute 被调用 (mock) ----
|
||||
{
|
||||
g_mock_tool_called = 0;
|
||||
int reg = tools->register_tool(
|
||||
"__w21_5_mock",
|
||||
"W21.5 mock tool for boundary test",
|
||||
"{}",
|
||||
mock_tool_handler
|
||||
);
|
||||
REGCHECK(reg == 0, "W21.5-3a: register mock tool ok");
|
||||
|
||||
char* result = tools->execute("__w21_5_mock", "{}");
|
||||
REGCHECK(g_mock_tool_called == 1,
|
||||
"W21.5-3b: mock tool handler was called (execute works)");
|
||||
if (result) {
|
||||
REGCHECK(std::strstr(result, "mock_result") != nullptr,
|
||||
"W21.5-3c: execute returned mock result json");
|
||||
dstalk_free(result);
|
||||
}
|
||||
|
||||
tools->unregister_tool("__w21_5_mock");
|
||||
|
||||
// 验证已注销的工具返回 error 而非崩溃
|
||||
char* err_result = tools->execute("__w21_5_mock", "{}");
|
||||
REGCHECK(err_result && std::strstr(err_result, "error") != nullptr,
|
||||
"W21.5-3d: unregistered tool returns error (not crash)");
|
||||
if (err_result) dstalk_free(err_result);
|
||||
}
|
||||
|
||||
// ---- W21.5-4: save/load 往返保留 tool_calls_json ----
|
||||
if (file_io) {
|
||||
const auto rtt_path = dir / "w21_5_tc_rtt.jsonl";
|
||||
int ret = session->save(rtt_path.string().c_str());
|
||||
REGCHECK(ret == 0, "W21.5-4a: session save with tool_calls msgs ok");
|
||||
|
||||
session->clear();
|
||||
ret = session->load(rtt_path.string().c_str());
|
||||
REGCHECK(ret == 0, "W21.5-4b: session load round-trip ok");
|
||||
|
||||
int count = 0;
|
||||
session->history(&count);
|
||||
REGCHECK(count > 0, "W21.5-4c: history non-empty after load round-trip");
|
||||
}
|
||||
|
||||
session->clear();
|
||||
} else {
|
||||
std::cerr << "[WARN] W21.5: tools or session service not available\n";
|
||||
}
|
||||
|
||||
// 清理
|
||||
dstalk_shutdown();
|
||||
std::cout << "[OK] dstalk_shutdown succeeded\n";
|
||||
|
||||
Reference in New Issue
Block a user