From ffe8fc5492197957e4bcb93a678f6d2d7c73df1e Mon Sep 17 00:00:00 2001 From: gigo Date: Sat, 16 May 2026 10:49:56 +0800 Subject: [PATCH] Add unit coverage for core and bare-metal HALs --- .github/workflows/ci.yml | 4 +- CMakeLists.txt | 21 ++++ tests/test_baremetal_hal.c | 193 +++++++++++++++++++++++++++++++++++++ tests/test_unit.c | 147 ++++++++++++++++++++++++++++ 4 files changed, 363 insertions(+), 2 deletions(-) create mode 100644 tests/test_baremetal_hal.c create mode 100644 tests/test_unit.c diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ddb70b..931c57a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,11 +16,11 @@ jobs: - name: posix platform: POSIX host_sim: OFF - ctest_regex: e2e|telnet-io + ctest_regex: e2e|unit|baremetal-hal|telnet-io - name: host-sim platform: POSIX host_sim: ON - ctest_regex: e2e|host-sim|telnet-io + ctest_regex: e2e|unit|baremetal-hal|host-sim|telnet-io - name: baremetal platform: BAREMETAL host_sim: OFF diff --git a/CMakeLists.txt b/CMakeLists.txt index 6349a1d..c72fca0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -175,6 +175,25 @@ if(EC_PLATFORM STREQUAL "POSIX") target_include_directories(embedclaw_telnet_tests PRIVATE include tests) target_compile_definitions(embedclaw_telnet_tests PRIVATE EC_CONFIG_TELNET_PORT=24232) + add_executable(embedclaw_unit_tests + src/core/ec_json.c + src/core/ec_session.c + tests/test_unit.c + ) + target_include_directories(embedclaw_unit_tests PRIVATE include tests) + + add_executable(embedclaw_baremetal_hal_tests + src/core/ec_io.c + src/platform/baremetal/ec_io_baremetal_uart.c + src/platform/baremetal/ec_socket_baremetal.c + tests/test_baremetal_hal.c + ) + target_include_directories(embedclaw_baremetal_hal_tests PRIVATE include tests) + target_compile_definitions(embedclaw_baremetal_hal_tests PRIVATE + EC_PLATFORM_BAREMETAL + EC_CONFIG_USE_TLS=0 + ) + if(EC_HOST_SIM) add_executable(embedclaw_host_sim_tests ${EC_SOURCES_NO_HTTP} @@ -190,6 +209,8 @@ if(EC_PLATFORM STREQUAL "POSIX") enable_testing() add_test(NAME e2e COMMAND embedclaw_tests) + add_test(NAME unit COMMAND embedclaw_unit_tests) + add_test(NAME baremetal-hal COMMAND embedclaw_baremetal_hal_tests) add_test(NAME telnet-io COMMAND embedclaw_telnet_tests) if(EC_HOST_SIM) add_test(NAME host-sim COMMAND embedclaw_host_sim_tests) diff --git a/tests/test_baremetal_hal.c b/tests/test_baremetal_hal.c new file mode 100644 index 0000000..ae230c4 --- /dev/null +++ b/tests/test_baremetal_hal.c @@ -0,0 +1,193 @@ +#include "test_runner.h" +#include "ec_io.h" +#include "ec_socket.h" + +#include +#include + +typedef struct { + const char *input; + size_t input_pos; + char output[128]; + size_t output_len; +} fake_uart_t; + +static int fake_uart_read(void *buf, size_t len, fake_uart_t *uart) +{ + (void)len; + if (!uart->input[uart->input_pos]) return 0; + *(char *)buf = uart->input[uart->input_pos++]; + return 1; +} + +static int fake_uart_write(const void *buf, size_t len, fake_uart_t *uart) +{ + if (uart->output_len + len >= sizeof(uart->output)) return -1; + memcpy(uart->output + uart->output_len, buf, len); + uart->output_len += len; + uart->output[uart->output_len] = '\0'; + return (int)len; +} + +static fake_uart_t *s_uart_ctx; + +static int uart_read_wrapper(void *buf, size_t len, uint32_t timeout_ms) +{ + (void)timeout_ms; + return fake_uart_read(buf, len, s_uart_ctx); +} + +static int uart_write_wrapper(const void *buf, size_t len, uint32_t timeout_ms) +{ + (void)timeout_ms; + return fake_uart_write(buf, len, s_uart_ctx); +} + +typedef struct { + int connect_calls; + int send_calls; + int recv_calls; + int close_calls; + const char *host; + uint16_t port; + int use_tls; + char sent[64]; + const char *recv_data; +} fake_socket_t; + +static int fake_connect(void *ctx, const char *host, uint16_t port, int use_tls) +{ + fake_socket_t *sock = (fake_socket_t *)ctx; + sock->connect_calls++; + sock->host = host; + sock->port = port; + sock->use_tls = use_tls; + return 0; +} + +static int fake_send(void *ctx, const void *data, size_t len) +{ + fake_socket_t *sock = (fake_socket_t *)ctx; + sock->send_calls++; + if (len >= sizeof(sock->sent)) return -1; + memcpy(sock->sent, data, len); + sock->sent[len] = '\0'; + return (int)len; +} + +static int fake_recv(void *ctx, void *buf, size_t len, uint32_t timeout_ms) +{ + fake_socket_t *sock = (fake_socket_t *)ctx; + (void)timeout_ms; + sock->recv_calls++; + size_t n = strlen(sock->recv_data); + if (n > len) n = len; + memcpy(buf, sock->recv_data, n); + return (int)n; +} + +static void fake_close(void *ctx) +{ + fake_socket_t *sock = (fake_socket_t *)ctx; + sock->close_calls++; +} + +static int test_baremetal_uart_reads_lines_and_writes(void) +{ + fake_uart_t uart; + memset(&uart, 0, sizeof(uart)); + uart.input = "hello\r\n"; + s_uart_ctx = &uart; + + ec_io_uart_hal_t hal = { + .read = uart_read_wrapper, + .write = uart_write_wrapper, + }; + ec_io_uart_set_hal(&hal); + ec_io_init(&ec_io_uart_ops); + + char line[16]; + ASSERT_EQ(ec_io_read_line(line, sizeof(line)), 5, + "bare-metal UART should read a line"); + ASSERT_EQ(strcmp(line, "hello"), 0, "bare-metal UART should strip CRLF"); + ASSERT_EQ(ec_io_write("ok"), 0, "bare-metal UART should write"); + ASSERT_EQ(strcmp(uart.output, "ok"), 0, "write should reach HAL"); + return 1; +} + +static int test_baremetal_uart_truncates_long_lines(void) +{ + fake_uart_t uart; + memset(&uart, 0, sizeof(uart)); + uart.input = "abcdef\n"; + s_uart_ctx = &uart; + + ec_io_uart_hal_t hal = { + .read = uart_read_wrapper, + .write = uart_write_wrapper, + }; + ec_io_uart_set_hal(&hal); + ec_io_init(&ec_io_uart_ops); + + char line[4]; + ASSERT_EQ(ec_io_read_line(line, sizeof(line)), 0, + "truncated line should return an empty command"); + ASSERT_EQ(line[0], '\0', "truncated line should clear output buffer"); + ASSERT_STR(uart.output, "input truncated", + "truncation warning should be written through HAL"); + return 1; +} + +static int test_baremetal_socket_delegates_to_hal(void) +{ + fake_socket_t fake; + memset(&fake, 0, sizeof(fake)); + fake.recv_data = "HTTP/1.1 200 OK\r\n\r\n{}"; + + ec_socket_baremetal_hal_t hal = { + .ctx = &fake, + .connect = fake_connect, + .send = fake_send, + .recv = fake_recv, + .close = fake_close, + }; + ec_socket_baremetal_set_hal(&hal); + + ec_socket_t *sock = ec_socket_connect("api.example.test", 443, 1); + ASSERT(sock != 0, "connect should return a socket handle"); + ASSERT_EQ(fake.connect_calls, 1, "connect callback should run"); + ASSERT_EQ(strcmp(fake.host, "api.example.test"), 0, "host should be passed through"); + ASSERT_EQ(fake.port, 443, "port should be passed through"); + ASSERT_EQ(fake.use_tls, 1, "TLS flag should be passed through"); + + ASSERT(ec_socket_connect("second.example.test", 80, 0) == 0, + "single static bare-metal socket should reject concurrent connect"); + + ASSERT_EQ(ec_socket_send(sock, "ping", 4), 4, "send should delegate to HAL"); + ASSERT_EQ(strcmp(fake.sent, "ping"), 0, "send payload should reach HAL"); + + char buf[32]; + int n = ec_socket_recv(sock, buf, sizeof(buf), 100); + ASSERT(n > 0, "recv should delegate to HAL"); + buf[n] = '\0'; + ASSERT_STR(buf, "HTTP/1.1 200 OK", "recv payload should come from HAL"); + + ec_socket_close(sock); + ASSERT_EQ(fake.close_calls, 1, "close callback should run"); + ASSERT(ec_socket_connect("after-close.example.test", 80, 0) != 0, + "connect should work again after close"); + ec_socket_close(sock); + return 1; +} + +int main(void) +{ + printf("=== EmbedClaw Bare-metal HAL Tests ===\n\n"); + + RUN_TEST(test_baremetal_uart_reads_lines_and_writes); + RUN_TEST(test_baremetal_uart_truncates_long_lines); + RUN_TEST(test_baremetal_socket_delegates_to_hal); + + PRINT_RESULTS(); + return (_tests_pass == _tests_run) ? 0 : 1; +} diff --git a/tests/test_unit.c b/tests/test_unit.c new file mode 100644 index 0000000..d0bc010 --- /dev/null +++ b/tests/test_unit.c @@ -0,0 +1,147 @@ +#include "test_runner.h" +#include "ec_json.h" +#include "ec_session.h" + +#include + +static int test_json_writer_escapes_and_composes(void) +{ + char buf[256]; + ec_json_writer_t w; + + ec_json_writer_init(&w, buf, sizeof(buf)); + ec_json_obj_start(&w); + ec_json_add_string(&w, "text", "quote: \" slash: \\ newline:\n"); + ec_json_add_int(&w, "count", 3); + ec_json_array_start(&w, "items"); + ec_json_array_obj_start(&w); + ec_json_add_string(&w, "name", "uart0"); + ec_json_obj_end(&w); + ec_json_array_end(&w); + ec_json_obj_end(&w); + + ASSERT(ec_json_writer_finish(&w) > 0, "writer should finish"); + ASSERT_STR(buf, "\"text\":\"quote: \\\" slash: \\\\ newline:\\n\"", + "writer should escape special characters"); + ASSERT_STR(buf, "\"count\":3", "writer should include integer fields"); + ASSERT_STR(buf, "\"items\":[{\"name\":\"uart0\"}]", + "writer should compose arrays of objects"); + return 1; +} + +static int test_json_writer_reports_overflow(void) +{ + char buf[24]; + ec_json_writer_t w; + + ec_json_writer_init(&w, buf, sizeof(buf)); + ec_json_obj_start(&w); + ec_json_add_string(&w, "too_long", "abcdefghijklmnopqrstuvwxyz"); + ec_json_obj_end(&w); + + ASSERT_EQ(ec_json_writer_finish(&w), -1, "small buffer should overflow"); + return 1; +} + +static int test_json_find_nested_strings_and_bounds(void) +{ + const char json[] = + "{\"choices\":[{\"message\":{\"content\":\"hello\"," + "\"tool_calls\":[{\"function\":{\"name\":\"hw_register_read\"}}]}}]}"; + char out[32]; + + ASSERT_EQ(ec_json_find_string(json, strlen(json), + "choices[0].message.content", + out, sizeof(out)), 5, + "nested string lookup should return length"); + ASSERT_EQ(strcmp(out, "hello"), 0, "nested string lookup should copy value"); + + ASSERT(ec_json_find_string(json, strlen(json), + "choices[0].message.tool_calls[0].function.name", + out, sizeof(out)) > 0, + "array path lookup should succeed"); + ASSERT_EQ(strcmp(out, "hw_register_read"), 0, + "array path lookup should copy tool name"); + + ASSERT(ec_json_find_string(json, strlen(json), + "choices[1].message.content", + out, sizeof(out)) < 0, + "out-of-range array lookup should fail"); + memset(out, 'x', sizeof(out)); + ASSERT_EQ(ec_json_find_string(json, strlen(json), + "choices[0].message.content", + out, 4), 5, + "undersized output buffer should still report full value length"); + ASSERT_EQ(strcmp(out, "hel"), 0, + "undersized output buffer should contain a safe truncated string"); + return 1; +} + +static int test_session_message_view_and_reset(void) +{ + ec_session_t session; + ec_model_tool_call_t call; + size_t count = 0; + + memset(&call, 0, sizeof(call)); + strcpy(call.id, "call_1"); + strcpy(call.name, "hw_register_read"); + strcpy(call.arguments, "{\"address\":\"0x40000000\"}"); + + ec_session_init(&session, "system prompt"); + ASSERT_EQ(ec_session_append(&session, "user", "hello"), 0, + "user append should succeed"); + ASSERT_EQ(ec_session_append_tool_calls(&session, &call, 1), 0, + "assistant tool-call append should succeed"); + ASSERT_EQ(ec_session_append_tool_result(&session, "call_1", + "{\"value\":\"0x00000000\"}"), 0, + "tool result append should succeed"); + + const ec_model_message_t *messages = ec_session_messages(&session, &count); + ASSERT_EQ(count, 4, "message view should include system plus three entries"); + ASSERT_EQ(strcmp(messages[0].role, "system"), 0, "first message is system"); + ASSERT_EQ(strcmp(messages[1].role, "user"), 0, "second message is user"); + ASSERT_EQ(strcmp(messages[2].role, "assistant"), 0, + "third message is assistant tool call"); + ASSERT_EQ(messages[2].num_tool_calls, 1, "assistant view should expose tool call"); + ASSERT_EQ(strcmp(messages[2].tool_calls[0].id, "call_1"), 0, + "tool call id should be preserved"); + ASSERT_EQ(strcmp(messages[3].role, "tool"), 0, "fourth message is tool result"); + ASSERT_EQ(strcmp(messages[3].tool_call_id, "call_1"), 0, + "tool result call id should be preserved"); + + ec_session_reset(&session); + messages = ec_session_messages(&session, &count); + ASSERT_EQ(count, 1, "reset should retain only system prompt"); + ASSERT_EQ(strcmp(messages[0].content, "system prompt"), 0, + "system prompt should survive reset"); + return 1; +} + +static int test_session_rejects_overflow(void) +{ + ec_session_t session; + ec_session_init(&session, "system prompt"); + + for (int i = 0; i < EC_CONFIG_MAX_HISTORY; i++) { + ASSERT_EQ(ec_session_append(&session, "user", "x"), 0, + "append before max history should succeed"); + } + ASSERT_EQ(ec_session_append(&session, "user", "overflow"), -1, + "append past max history should fail"); + return 1; +} + +int main(void) +{ + printf("=== EmbedClaw Unit Tests ===\n\n"); + + RUN_TEST(test_json_writer_escapes_and_composes); + RUN_TEST(test_json_writer_reports_overflow); + RUN_TEST(test_json_find_nested_strings_and_bounds); + RUN_TEST(test_session_message_view_and_reset); + RUN_TEST(test_session_rejects_overflow); + + PRINT_RESULTS(); + return (_tests_pass == _tests_run) ? 0 : 1; +}