test(ble): add comprehensive test suite for log compression

(cherry picked from commit 58121d2540)

Co-authored-by: luoxu <luoxu@espressif.com>
This commit is contained in:
Luo Xu
2026-04-24 17:44:10 +08:00
parent 5be9215c10
commit 215e6f5aa0
22 changed files with 2234 additions and 0 deletions

View File

@@ -0,0 +1,183 @@
# BLE Log Compression Test Suite
## Overview
This test suite validates the BLE log compression pipeline — from C source input through tree-sitter AST parsing, log ID assignment, compressed macro generation, to final `log_index.h` header output.
All tests use Python standard library `unittest` only. No third-party test frameworks required.
## Directory Structure
```
tests/
├── README.md # This file
├── test_utils.py # Shared utilities: path setup, PipelineContext, helpers
├── test_format_parser.py # FormatToken NamedTuple and parse_format_string
├── test_arg_size_types.py # ARG_SIZE_TYPE determination (U32/U64/STR)
├── test_db_manager.py # LogDBManager state persistence and incremental logic
├── test_log_extraction.py # Tree-sitter AST log extraction
├── test_macro_generation.py # Bluedroid and Mesh module macro generators
├── test_pipeline_e2e.py # End-to-end .c -> .h with golden file comparison
├── test_incremental.py # Incremental compression scenarios
├── update_golden.py # Script to regenerate golden expected files
└── fixtures/
├── c_sources/ # Test input C files
│ ├── simple_logs.c # Basic: no-arg, single-arg, multi-arg, string
│ ├── format_specifiers.c # All format -> size type mappings
│ ├── special_tokens.c # __func__, __LINE__, MAC2STR, bt_hex
│ ├── concatenated_strings.c # PRIu64, PRId64 macro concatenation
│ ├── multi_level.c # ERROR/WARNING/API/DEBUG/EVENT/VERBOSE
│ ├── no_logs.c # Valid C with no matching log tags
│ └── mesh_logs.c # BLE Mesh module log patterns
└── expected/ # Golden output headers for comparison
├── simple_logs_index.h
├── format_specifiers_index.h
├── special_tokens_index.h
├── multi_level_index.h
└── multi_file_index.h
```
## Running Tests
From the `tests/` directory:
```bash
# Run all tests
python3 -m unittest discover -v
# Run a single test module
python3 -m unittest test_format_parser -v
# Run a single test class
python3 -m unittest test_arg_size_types.TestU64SizeType -v
# Run a single test method
python3 -m unittest test_db_manager.TestAddLog.test_sequential_ids -v
```
## Test Modules
### test_format_parser.py — Format String Parsing
Validates `FormatToken(NamedTuple)` and `parse_format_string()` in `c_format_parse.py`.
| Test Class | Coverage |
|---|---|
| `TestFormatTokenStructure` | NamedTuple field names, named vs index access, tuple subclass |
| `TestBasicSpecifiers` | 18 conversion characters (`%d`, `%s`, `%f`, `%p`, ...), escaped `%%`, empty string |
| `TestPositionTracking` | `FormatToken.start` / `.end` positions in source string |
| `TestLengthModifiers` | `h`, `hh`, `l`, `ll`, `z`, `j`, `t` |
| `TestWidthAndPrecision` | Fixed width/precision, dynamic `%*d` / `%.*s`, both dynamic |
| `TestFlags` | `-`, `+`, ` `, `#`, `0` |
| `TestMixedContent` | Interleaved text and specifiers, quoted format strings |
### test_arg_size_types.py — Size Type Determination
**Core regression test for the off-by-one bug fix.** Verifies that `FormatToken.conv_char` and `FormatToken.length` (not the old incorrect indices) are used to determine `ARG_SIZE_TYPE`.
| Format | Expected Size Type | Field Checked |
|---|---|---|
| `%d`, `%u`, `%x`, `%X`, `%o` | `U32` (0) | `token.conv_char` |
| `%s` | `STR` (1) | `token.conv_char` |
| `%f` | `U64` (2) | `token.conv_char` |
| `%lld`, `%llx` | `U64` (2) | `token.length` |
| `__func__` / `__LINE__` | skipped | `token.conv_char == '@func'` / `'@line'` |
| `MAC2STR(addr)` | buffer arg | `token.conv_char.startswith('@hex_func')` |
### test_db_manager.py — Database State Management
Tests `LogDBManager` in `LogDBManager.py`.
| Test Class | Coverage |
|---|---|
| `TestAddLog` | SUCCESS / LOG_EXISTS return codes, sequential IDs, unique key fields |
| `TestPersistence` | `save_all()` + reload preserves data |
| `TestFileProcessing` | `is_file_processed`: new file, unchanged, modified |
| `TestConfigUpdate` | `is_config_updated`: fresh DB, same config after reload, changed config |
| `TestSourceUpdateState` | `SOURCE_LOG_UPDATE_FULL` / `PARTIAL` / `NONE` transitions |
### test_log_extraction.py — AST Log Extraction
Tests `LogCompressor.extract_log_calls()` using tree-sitter.
| Test Class | Coverage |
|---|---|
| `TestExtractNoLogs` | No matching tags, no functions |
| `TestExtractBasicLogs` | Single log, no-arg log, multiple tags |
| `TestExtractFormatTokens` | Token population, hexify=True, dynamic width/precision filtered |
| `TestExtractSpecialTokens` | `__func__` -> `@func`, `__LINE__` -> `@line`, `MAC2STR` -> `@hex_func` |
| `TestExtractCallerInfo` | Correct enclosing function identification |
| `TestExtractArgMismatch` | Format/argument count mismatch raises `SyntaxError` |
### test_macro_generation.py — Module-Specific Macro Generators
Tests `make_bluedroid_log_macro.py` and `make_mesh_log_macro.py` directly.
| Test Class | Coverage |
|---|---|
| `TestBluedroidMacroGen` | `gen_header_head`, zero/single/multi-arg macros, level checks (`appl_trace_level >= BT_TRACE_LEVEL_*`), buffer args |
| `TestMeshMacroGen` | Header declarations, `BT_ERR`/`BT_DBG` level checks |
### test_pipeline_e2e.py — End-to-End Pipeline
Full `.c` -> `.h` pipeline tests with golden file comparison.
| Test Class | Coverage |
|---|---|
| `TestSimpleLogsE2E` | Header generation, tag replacement in source, header structure, sorted IDs, preserve tags |
| `TestFormatSpecifiersE2E` | All size types (U32/STR/U64) present in output |
| `TestSpecialTokensE2E` | `ble_log_compressed_hex_print_buf` generated, `__func__` excluded |
| `TestNoLogsE2E` | Zero macros for file without matching tags |
| `TestMultiLevelE2E` | Unique IDs per level, different level check prefixes |
| `TestMultiFileE2E` | Globally unique IDs across multiple files |
| `TestConcatenatedStringsE2E` | `PRIu64` produces U64 size type |
| `TestGoldenFileComparison` | Exact output comparison against `fixtures/expected/*.h` |
### test_incremental.py — Incremental Compression
Tests caching, re-runs, and config change behavior.
| Test Class | Coverage |
|---|---|
| `TestFirstRun` | All files compressed, header generated |
| `TestRerunUnchanged` | File hashes tracked, log IDs stable on re-add |
| `TestRerunModifiedFile` | Modified file reprocessed, existing IDs stable |
| `TestAddNewFile` | New file IDs continue from `max_id + 1` |
| `TestConfigChange` | Config change triggers `SOURCE_LOG_UPDATE_FULL` |
## Golden File Workflow
The `fixtures/expected/` directory contains known-good header outputs. End-to-end tests compare generated output against these files (with copyright year normalized).
When the compression logic changes intentionally:
```bash
# Regenerate all golden files
python3 update_golden.py
# Review the diff
git diff fixtures/expected/
# Commit after verification
```
## Fixture C Source Files
These files are parsed by tree-sitter only — they do not need to compile. Clang warnings about implicit declarations and unknown types are expected and irrelevant.
| File | Purpose |
|---|---|
| `simple_logs.c` | 4 functions: no-arg, single `%d`, triple `%d`, `%s` |
| `format_specifiers.c` | All `ARG_SIZE_TYPE` mappings: `%d/%u/%x/%X/%o` (U32), `%f` (U64), `%lld/%llx` (U64), `%s` (STR), mixed |
| `special_tokens.c` | `__func__`, `__FUNCTION__`, `__LINE__`, `MAC2STR`, mixed special + normal args |
| `concatenated_strings.c` | `PRIu64`, `PRId64`, `PRIx64` macro concatenation |
| `multi_level.c` | ERROR, WARNING, API, DEBUG, EVENT, VERBOSE in one function |
| `no_logs.c` | Only `printf`, no matching log tags |
| `mesh_logs.c` | BLE Mesh tags: `BT_ERR`, `BT_WARN`, `BT_INFO`, `BT_DBG` |
## Dependencies
- Python 3.8+
- `tree-sitter` + `tree-sitter-c` (same versions as in `scripts/requirements.txt`)
- `pyyaml`
- No additional test frameworks required (`unittest` is in the Python standard library)

View File

@@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
/* Test fixture: PRIu64 and other macro-concatenated format strings */
void test_pri_u64(uint64_t val64) {
APPL_TRACE_DEBUG("val %" PRIu64, val64);
}
void test_pri_d64(int64_t sval64) {
APPL_TRACE_DEBUG("signed %" PRId64, sval64);
}
void test_pri_x64(uint64_t hval64) {
APPL_TRACE_DEBUG("hex %" PRIx64, hval64);
}

View File

@@ -0,0 +1,30 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
/* Test fixture: all format specifier -> size type mappings */
void test_u32_types(int a, unsigned int b, int c, int d, int e) {
APPL_TRACE_DEBUG("int:%d unsigned:%u hex:%x HEX:%X octal:%o", a, b, c, d, e);
}
void test_u64_float(double f_val) {
APPL_TRACE_DEBUG("float: %f", f_val);
}
void test_u64_long_long(long long ll_val, long long llx_val) {
APPL_TRACE_DEBUG("long long: %lld hex_ll: %llx", ll_val, llx_val);
}
void test_str_type(const char *str1, const char *str2) {
APPL_TRACE_DEBUG("string: %s, another: %s", str1, str2);
}
void test_mixed_types(int i, const char *s, long long ll, double f, int h) {
APPL_TRACE_DEBUG("mixed: %d %s %lld %f %x", i, s, ll, f, h);
}
void test_width_flags(int val, int num, const char *str) {
APPL_TRACE_DEBUG("padded: %08x, width: %10d, left: %-20s", val, num, str);
}

View File

@@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
/* Test fixture: BLE Mesh module log patterns */
void mesh_error_func(int err) {
BT_ERR("Mesh error %d", err);
}
void mesh_warn_func(int code) {
BT_WARN("Mesh warning %d", code);
}
void mesh_info_func(void) {
BT_INFO("Mesh info");
}
void mesh_debug_func(int val) {
BT_DBG("Mesh debug %d", val);
}

View File

@@ -0,0 +1,15 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
/* Test fixture: multiple log levels in one function */
void multi_level_func(int status, int handle) {
APPL_TRACE_ERROR("critical error %d", status);
APPL_TRACE_WARNING("warning condition %d", status);
APPL_TRACE_API("API call with handle %d", handle);
APPL_TRACE_DEBUG("debug info %d %d", status, handle);
APPL_TRACE_EVENT("event occurred");
APPL_TRACE_VERBOSE("verbose detail %d", handle);
}

View File

@@ -0,0 +1,13 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
/* Test fixture: valid C file with no matching log tags */
#include <stdio.h>
void regular_function(int a, int b) {
int c = a + b;
printf("Result: %d\n", c);
}

View File

@@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
/* Test fixture: basic log patterns */
void simple_no_args(void) {
APPL_TRACE_DEBUG("Simple message with no args");
}
void simple_one_int(int val) {
APPL_TRACE_ERROR("Value is %d", val);
}
void simple_multi_int(int a, int b, int c) {
APPL_TRACE_API("Values: %d, %d, %d", a, b, c);
}
void simple_string(const char *name) {
APPL_TRACE_WARNING("Name is %s", name);
}

View File

@@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
/* Test fixture: __func__, __LINE__, MAC2STR, bt_hex */
void test_func_macro(void) {
APPL_TRACE_DEBUG("%s starting", __func__);
}
void test_function_macro(void) {
APPL_TRACE_DEBUG("%s called", __FUNCTION__);
}
void test_line_macro(int err_code) {
APPL_TRACE_ERROR("%s error at line %d with code %d", __func__, __LINE__, err_code);
}
void test_mac2str(const uint8_t *bd_addr) {
APPL_TRACE_DEBUG("addr=" MACSTR, MAC2STR(bd_addr));
}
void test_mixed_special(int handle, const uint8_t *addr) {
APPL_TRACE_DEBUG("%s handle=0x%x addr=" MACSTR, __func__, handle, MAC2STR(addr));
}

View File

@@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#ifndef __BLE_HOST_INTERNAL_LOG_INDEX_H
#define __BLE_HOST_INTERNAL_LOG_INDEX_H
#include <stddef.h>
#include <stdlib.h>
// Compression function declarations
extern int ble_log_compressed_hex_print
(uint8_t source, uint32_t log_index, size_t args_size_cnt, ...);
extern int ble_log_compressed_hex_print_buf
(uint8_t source, uint32_t log_index, uint8_t buf_idx, const uint8_t *buf, size_t len);
#define APPL_TRACE_DEBUG_1(fmt, ...) {\
{if (appl_trace_level >= BT_TRACE_LEVEL_DEBUG && BT_LOG_LEVEL_CHECK(APPL, DEBUG)) ble_log_compressed_hex_print(0,1, 5, 0, 0, 0, 0, 0, a, b, c, d, e);\
;}\
}
#define APPL_TRACE_DEBUG_2(fmt, ...) {\
{if (appl_trace_level >= BT_TRACE_LEVEL_DEBUG && BT_LOG_LEVEL_CHECK(APPL, DEBUG)) ble_log_compressed_hex_print(0,2, 1, 2, f_val);\
;}\
}
#define APPL_TRACE_DEBUG_3(fmt, ...) {\
{if (appl_trace_level >= BT_TRACE_LEVEL_DEBUG && BT_LOG_LEVEL_CHECK(APPL, DEBUG)) ble_log_compressed_hex_print(0,3, 2, 2, 2, ll_val, llx_val);\
;}\
}
#define APPL_TRACE_DEBUG_4(fmt, ...) {\
{if (appl_trace_level >= BT_TRACE_LEVEL_DEBUG && BT_LOG_LEVEL_CHECK(APPL, DEBUG)) ble_log_compressed_hex_print(0,4, 2, 1, 1, str1, str2);\
;}\
}
#define APPL_TRACE_DEBUG_5(fmt, ...) {\
{if (appl_trace_level >= BT_TRACE_LEVEL_DEBUG && BT_LOG_LEVEL_CHECK(APPL, DEBUG)) ble_log_compressed_hex_print(0,5, 5, 0, 1, 2, 2, 0, i, s, ll, f, h);\
;}\
}
#define APPL_TRACE_DEBUG_6(fmt, ...) {\
{if (appl_trace_level >= BT_TRACE_LEVEL_DEBUG && BT_LOG_LEVEL_CHECK(APPL, DEBUG)) ble_log_compressed_hex_print(0,6, 3, 0, 0, 1, val, num, str);\
;}\
}
#endif // __BLE_HOST_INTERNAL_LOG_INDEX_H

View File

@@ -0,0 +1,73 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#ifndef __BLE_HOST_INTERNAL_LOG_INDEX_H
#define __BLE_HOST_INTERNAL_LOG_INDEX_H
#include <stddef.h>
#include <stdlib.h>
// Compression function declarations
extern int ble_log_compressed_hex_print
(uint8_t source, uint32_t log_index, size_t args_size_cnt, ...);
extern int ble_log_compressed_hex_print_buf
(uint8_t source, uint32_t log_index, uint8_t buf_idx, const uint8_t *buf, size_t len);
#define APPL_TRACE_ERROR_1(fmt, ...) {\
{if (appl_trace_level >= BT_TRACE_LEVEL_ERROR && BT_LOG_LEVEL_CHECK(APPL, ERROR)) ble_log_compressed_hex_print(0,1, 1, 0, status);\
;}\
APPL_TRACE_ERROR(fmt, ##__VA_ARGS__);\
}
#define APPL_TRACE_WARNING_2(fmt, ...) {\
{if (appl_trace_level >= BT_TRACE_LEVEL_WARNING && BT_LOG_LEVEL_CHECK(APPL, WARNING)) ble_log_compressed_hex_print(0,2, 1, 0, status);\
;}\
APPL_TRACE_WARNING(fmt, ##__VA_ARGS__);\
}
#define APPL_TRACE_API_3(fmt, ...) {\
{if (appl_trace_level >= BT_TRACE_LEVEL_API && BT_LOG_LEVEL_CHECK(APPL, API)) ble_log_compressed_hex_print(0,3, 1, 0, handle);\
;}\
}
#define APPL_TRACE_DEBUG_4(fmt, ...) {\
{if (appl_trace_level >= BT_TRACE_LEVEL_DEBUG && BT_LOG_LEVEL_CHECK(APPL, DEBUG)) ble_log_compressed_hex_print(0,4, 2, 0, 0, status, handle);\
;}\
}
#define APPL_TRACE_EVENT_5(fmt, ...) {\
{if (appl_trace_level >= BT_TRACE_LEVEL_EVENT && BT_LOG_LEVEL_CHECK(APPL, EVENT)) ble_log_compressed_hex_print(0,5, 0);\
;}\
}
#define APPL_TRACE_VERBOSE_6(fmt, ...) {\
{if (appl_trace_level >= BT_TRACE_LEVEL_VERBOSE && BT_LOG_LEVEL_CHECK(APPL, VERBOSE)) ble_log_compressed_hex_print(0,6, 1, 0, handle);\
;}\
}
#define APPL_TRACE_DEBUG_7(fmt, ...) {\
{if (appl_trace_level >= BT_TRACE_LEVEL_DEBUG && BT_LOG_LEVEL_CHECK(APPL, DEBUG)) ble_log_compressed_hex_print(0,7, 0);\
;}\
}
#define APPL_TRACE_ERROR_8(fmt, ...) {\
{if (appl_trace_level >= BT_TRACE_LEVEL_ERROR && BT_LOG_LEVEL_CHECK(APPL, ERROR)) ble_log_compressed_hex_print(0,8, 1, 0, val);\
;}\
APPL_TRACE_ERROR(fmt, ##__VA_ARGS__);\
}
#define APPL_TRACE_API_9(fmt, ...) {\
{if (appl_trace_level >= BT_TRACE_LEVEL_API && BT_LOG_LEVEL_CHECK(APPL, API)) ble_log_compressed_hex_print(0,9, 3, 0, 0, 0, a, b, c);\
;}\
}
#define APPL_TRACE_WARNING_10(fmt, ...) {\
{if (appl_trace_level >= BT_TRACE_LEVEL_WARNING && BT_LOG_LEVEL_CHECK(APPL, WARNING)) ble_log_compressed_hex_print(0,10, 1, 1, name);\
;}\
APPL_TRACE_WARNING(fmt, ##__VA_ARGS__);\
}
#endif // __BLE_HOST_INTERNAL_LOG_INDEX_H

View File

@@ -0,0 +1,51 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#ifndef __BLE_HOST_INTERNAL_LOG_INDEX_H
#define __BLE_HOST_INTERNAL_LOG_INDEX_H
#include <stddef.h>
#include <stdlib.h>
// Compression function declarations
extern int ble_log_compressed_hex_print
(uint8_t source, uint32_t log_index, size_t args_size_cnt, ...);
extern int ble_log_compressed_hex_print_buf
(uint8_t source, uint32_t log_index, uint8_t buf_idx, const uint8_t *buf, size_t len);
#define APPL_TRACE_ERROR_1(fmt, ...) {\
{if (appl_trace_level >= BT_TRACE_LEVEL_ERROR && BT_LOG_LEVEL_CHECK(APPL, ERROR)) ble_log_compressed_hex_print(0,1, 1, 0, status);\
;}\
APPL_TRACE_ERROR(fmt, ##__VA_ARGS__);\
}
#define APPL_TRACE_WARNING_2(fmt, ...) {\
{if (appl_trace_level >= BT_TRACE_LEVEL_WARNING && BT_LOG_LEVEL_CHECK(APPL, WARNING)) ble_log_compressed_hex_print(0,2, 1, 0, status);\
;}\
APPL_TRACE_WARNING(fmt, ##__VA_ARGS__);\
}
#define APPL_TRACE_API_3(fmt, ...) {\
{if (appl_trace_level >= BT_TRACE_LEVEL_API && BT_LOG_LEVEL_CHECK(APPL, API)) ble_log_compressed_hex_print(0,3, 1, 0, handle);\
;}\
}
#define APPL_TRACE_DEBUG_4(fmt, ...) {\
{if (appl_trace_level >= BT_TRACE_LEVEL_DEBUG && BT_LOG_LEVEL_CHECK(APPL, DEBUG)) ble_log_compressed_hex_print(0,4, 2, 0, 0, status, handle);\
;}\
}
#define APPL_TRACE_EVENT_5(fmt, ...) {\
{if (appl_trace_level >= BT_TRACE_LEVEL_EVENT && BT_LOG_LEVEL_CHECK(APPL, EVENT)) ble_log_compressed_hex_print(0,5, 0);\
;}\
}
#define APPL_TRACE_VERBOSE_6(fmt, ...) {\
{if (appl_trace_level >= BT_TRACE_LEVEL_VERBOSE && BT_LOG_LEVEL_CHECK(APPL, VERBOSE)) ble_log_compressed_hex_print(0,6, 1, 0, handle);\
;}\
}
#endif // __BLE_HOST_INTERNAL_LOG_INDEX_H

View File

@@ -0,0 +1,41 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#ifndef __BLE_HOST_INTERNAL_LOG_INDEX_H
#define __BLE_HOST_INTERNAL_LOG_INDEX_H
#include <stddef.h>
#include <stdlib.h>
// Compression function declarations
extern int ble_log_compressed_hex_print
(uint8_t source, uint32_t log_index, size_t args_size_cnt, ...);
extern int ble_log_compressed_hex_print_buf
(uint8_t source, uint32_t log_index, uint8_t buf_idx, const uint8_t *buf, size_t len);
#define APPL_TRACE_DEBUG_1(fmt, ...) {\
{if (appl_trace_level >= BT_TRACE_LEVEL_DEBUG && BT_LOG_LEVEL_CHECK(APPL, DEBUG)) ble_log_compressed_hex_print(0,1, 0);\
;}\
}
#define APPL_TRACE_ERROR_2(fmt, ...) {\
{if (appl_trace_level >= BT_TRACE_LEVEL_ERROR && BT_LOG_LEVEL_CHECK(APPL, ERROR)) ble_log_compressed_hex_print(0,2, 1, 0, val);\
;}\
APPL_TRACE_ERROR(fmt, ##__VA_ARGS__);\
}
#define APPL_TRACE_API_3(fmt, ...) {\
{if (appl_trace_level >= BT_TRACE_LEVEL_API && BT_LOG_LEVEL_CHECK(APPL, API)) ble_log_compressed_hex_print(0,3, 3, 0, 0, 0, a, b, c);\
;}\
}
#define APPL_TRACE_WARNING_4(fmt, ...) {\
{if (appl_trace_level >= BT_TRACE_LEVEL_WARNING && BT_LOG_LEVEL_CHECK(APPL, WARNING)) ble_log_compressed_hex_print(0,4, 1, 1, name);\
;}\
APPL_TRACE_WARNING(fmt, ##__VA_ARGS__);\
}
#endif // __BLE_HOST_INTERNAL_LOG_INDEX_H

View File

@@ -0,0 +1,47 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#ifndef __BLE_HOST_INTERNAL_LOG_INDEX_H
#define __BLE_HOST_INTERNAL_LOG_INDEX_H
#include <stddef.h>
#include <stdlib.h>
// Compression function declarations
extern int ble_log_compressed_hex_print
(uint8_t source, uint32_t log_index, size_t args_size_cnt, ...);
extern int ble_log_compressed_hex_print_buf
(uint8_t source, uint32_t log_index, uint8_t buf_idx, const uint8_t *buf, size_t len);
#define APPL_TRACE_DEBUG_1(fmt, ...) {\
{if (appl_trace_level >= BT_TRACE_LEVEL_DEBUG && BT_LOG_LEVEL_CHECK(APPL, DEBUG)) ble_log_compressed_hex_print(0,1, 0);\
;}\
}
#define APPL_TRACE_DEBUG_2(fmt, ...) {\
{if (appl_trace_level >= BT_TRACE_LEVEL_DEBUG && BT_LOG_LEVEL_CHECK(APPL, DEBUG)) ble_log_compressed_hex_print(0,2, 0);\
;}\
}
#define APPL_TRACE_ERROR_3(fmt, ...) {\
{if (appl_trace_level >= BT_TRACE_LEVEL_ERROR && BT_LOG_LEVEL_CHECK(APPL, ERROR)) ble_log_compressed_hex_print(0,3, 1, 0, err_code);\
;}\
APPL_TRACE_ERROR(fmt, ##__VA_ARGS__);\
}
#define APPL_TRACE_DEBUG_4(fmt, ...) {\
{if (appl_trace_level >= BT_TRACE_LEVEL_DEBUG && BT_LOG_LEVEL_CHECK(APPL, DEBUG)) ble_log_compressed_hex_print(0,4, 0);\
ble_log_compressed_hex_print_buf(0, 4, 0, (const uint8_t *)bd_addr, 6);\
;}\
}
#define APPL_TRACE_DEBUG_5(fmt, ...) {\
{if (appl_trace_level >= BT_TRACE_LEVEL_DEBUG && BT_LOG_LEVEL_CHECK(APPL, DEBUG)) ble_log_compressed_hex_print(0,5, 1, 0, handle);\
ble_log_compressed_hex_print_buf(0, 5, 0, (const uint8_t *)addr, 6);\
;}\
}
#endif // __BLE_HOST_INTERNAL_LOG_INDEX_H

View File

@@ -0,0 +1,136 @@
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
"""Tests for argument size type determination in generate_compressed_macro.
Verifies that FormatToken.conv_char and FormatToken.length are used correctly
to determine ARG_SIZE_TYPE (the fix for the off-by-one bug).
"""
import re
import shutil
import tempfile
import unittest
import test_utils # noqa: F401 — must be first to set up sys.path
from ble_log_compress import ARG_SIZE_TYPE
from test_utils import PipelineContext
def _extract_size_types_from_macro(macro_str: str) -> list[int] | None:
"""Extract size type values from a generated macro string."""
m = re.search(r'ble_log_compressed_hex_print\(\d+,\d+, (\d+)(?:, (.+?))?(?:,\s*[a-zA-Z_])', macro_str)
if not m:
m = re.search(r'ble_log_compressed_hex_print\(\d+,\d+, 0\)', macro_str)
if m:
return []
return None
arg_count = int(m.group(1))
if arg_count == 0:
return []
size_str = m.group(2)
return [int(x.strip()) for x in size_str.split(',')[:arg_count]]
def _compress_and_get_macro(ctx: 'PipelineContext', c_code: bytes) -> str:
"""Compress a C code snippet and return the generated macro string."""
src_dir = ctx.code_base / 'test_src'
src_dir.mkdir(parents=True, exist_ok=True)
(src_dir / 'test_size.c').write_bytes(c_code)
tmp_dir = ctx.compressed_srcs / 'test_src'
tmp_dir.mkdir(parents=True, exist_ok=True)
(tmp_dir / 'test_size.c.tmp').write_bytes(c_code)
file_macros = ctx.compressor.compress_file(('BLE_HOST', str(tmp_dir / 'test_size.c.tmp')))
assert len(file_macros) > 0, 'No macros generated'
result: str = file_macros[0][2]
return result
class _PipelineTestCase(unittest.TestCase):
"""Base class that sets up and tears down a full pipeline context."""
def setUp(self) -> None:
self.tmp = tempfile.mkdtemp()
self.ctx = PipelineContext(self.tmp)
def tearDown(self) -> None:
self.ctx.close()
shutil.rmtree(self.tmp, ignore_errors=True)
class TestU32SizeType(_PipelineTestCase):
def test_int_d(self) -> None:
macro = _compress_and_get_macro(self.ctx, b'void f(void) { APPL_TRACE_DEBUG("v %d", a); }')
self.assertEqual(_extract_size_types_from_macro(macro), [int(ARG_SIZE_TYPE.U32)])
def test_unsigned_u(self) -> None:
macro = _compress_and_get_macro(self.ctx, b'void f(void) { APPL_TRACE_DEBUG("v %u", a); }')
self.assertEqual(_extract_size_types_from_macro(macro), [int(ARG_SIZE_TYPE.U32)])
def test_hex_x(self) -> None:
macro = _compress_and_get_macro(self.ctx, b'void f(void) { APPL_TRACE_DEBUG("v %x", a); }')
self.assertEqual(_extract_size_types_from_macro(macro), [int(ARG_SIZE_TYPE.U32)])
class TestU64SizeType(_PipelineTestCase):
def test_float_f(self) -> None:
macro = _compress_and_get_macro(self.ctx, b'void f(void) { APPL_TRACE_DEBUG("v %f", a); }')
self.assertEqual(_extract_size_types_from_macro(macro), [int(ARG_SIZE_TYPE.U64)])
def test_long_long_d(self) -> None:
macro = _compress_and_get_macro(self.ctx, b'void f(void) { APPL_TRACE_DEBUG("v %lld", a); }')
self.assertEqual(_extract_size_types_from_macro(macro), [int(ARG_SIZE_TYPE.U64)])
def test_long_long_x(self) -> None:
macro = _compress_and_get_macro(self.ctx, b'void f(void) { APPL_TRACE_DEBUG("v %llx", a); }')
self.assertEqual(_extract_size_types_from_macro(macro), [int(ARG_SIZE_TYPE.U64)])
class TestSTRSizeType(_PipelineTestCase):
def test_string_s(self) -> None:
macro = _compress_and_get_macro(self.ctx, b'void f(void) { APPL_TRACE_DEBUG("v %s", a); }')
self.assertEqual(_extract_size_types_from_macro(macro), [int(ARG_SIZE_TYPE.STR)])
class TestMixedSizeTypes(_PipelineTestCase):
def test_d_s_lld_f(self) -> None:
macro = _compress_and_get_macro(
self.ctx, b'void f(void) { APPL_TRACE_DEBUG("a %d b %s c %lld d %f", i, s, ll, fv); }'
)
self.assertEqual(
_extract_size_types_from_macro(macro),
[
int(ARG_SIZE_TYPE.U32),
int(ARG_SIZE_TYPE.STR),
int(ARG_SIZE_TYPE.U64),
int(ARG_SIZE_TYPE.U64),
],
)
class TestSpecialTokensSkipped(_PipelineTestCase):
def test_func_excluded_from_args(self) -> None:
macro = _compress_and_get_macro(self.ctx, b'void f(void) { APPL_TRACE_DEBUG("%s val %d", __func__, a); }')
self.assertEqual(_extract_size_types_from_macro(macro), [int(ARG_SIZE_TYPE.U32)])
def test_line_excluded_from_args(self) -> None:
macro = _compress_and_get_macro(self.ctx, b'void f(void) { APPL_TRACE_DEBUG("line %d val %d", __LINE__, a); }')
self.assertEqual(_extract_size_types_from_macro(macro), [int(ARG_SIZE_TYPE.U32)])
def test_zero_args_after_func(self) -> None:
macro = _compress_and_get_macro(self.ctx, b'void f(void) { APPL_TRACE_DEBUG("%s starting", __func__); }')
self.assertEqual(_extract_size_types_from_macro(macro), [])
class TestHexFuncSkipped(_PipelineTestCase):
def test_mac2str_generates_buf_print(self) -> None:
macro = _compress_and_get_macro(
self.ctx, b'void f(void) { APPL_TRACE_DEBUG("addr=" MACSTR, MAC2STR(bd_addr)); }'
)
self.assertIn('ble_log_compressed_hex_print_buf', macro)
self.assertIn('bd_addr', macro)
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,330 @@
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
"""Tests for LogDBManager: state persistence, incremental logic, file tracking."""
import shutil
import tempfile
import unittest
import test_utils # noqa: F401 — must be first to set up sys.path
from LogDBManager import LogDBManager
class TestAddLog(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.mkdtemp()
self.db = LogDBManager(data_dir=f'{self.tmp}/db', sources={'BLE_HOST': 'test_config_v1'})
def tearDown(self) -> None:
self.db.close()
shutil.rmtree(self.tmp, ignore_errors=True)
def test_add_new_returns_success(self) -> None:
result, log_id = self.db.add_log(
source='BLE_HOST',
log_tag='APPL_TRACE_DEBUG',
log_format='"message %d"',
log_line_number=10,
hexify=True,
caller_func='foo',
caller_line=5,
file_name='test.c',
)
self.assertEqual(result, LogDBManager.SUCCESS)
self.assertEqual(log_id, 1)
def test_add_duplicate_returns_exists(self) -> None:
kw = dict(
source='BLE_HOST',
log_tag='APPL_TRACE_DEBUG',
log_format='"message %d"',
log_line_number=10,
hexify=True,
caller_func='foo',
caller_line=5,
file_name='test.c',
)
self.db.add_log(**kw)
result, log_id = self.db.add_log(**kw)
self.assertEqual(result, LogDBManager.LOG_EXISTS)
self.assertEqual(log_id, 1)
def test_sequential_ids(self) -> None:
_, id1 = self.db.add_log(
source='BLE_HOST',
log_tag='T',
log_format='"m1"',
log_line_number=10,
hexify=True,
caller_func='foo',
caller_line=5,
file_name='test.c',
)
_, id2 = self.db.add_log(
source='BLE_HOST',
log_tag='T',
log_format='"m2"',
log_line_number=20,
hexify=True,
caller_func='bar',
caller_line=15,
file_name='test.c',
)
self.assertEqual(id1, 1)
self.assertEqual(id2, 2)
def test_unique_key_distinguishes_all_fields(self) -> None:
_, id1 = self.db.add_log(
source='BLE_HOST',
log_tag='T',
log_format='"msg %d"',
log_line_number=10,
hexify=True,
caller_func='foo',
caller_line=5,
file_name='a.c',
)
_, id2 = self.db.add_log(
source='BLE_HOST',
log_tag='T',
log_format='"msg %d"',
log_line_number=20,
hexify=True,
caller_func='bar',
caller_line=15,
file_name='b.c',
)
self.assertNotEqual(id1, id2)
class TestPersistence(unittest.TestCase):
def test_save_and_reload(self) -> None:
tmp = tempfile.mkdtemp()
try:
db_path = f'{tmp}/db'
sources = {'BLE_HOST': 'cfg_v1'}
mgr1 = LogDBManager(data_dir=db_path, sources=sources)
mgr1.add_log(
source='BLE_HOST',
log_tag='APPL_TRACE_ERROR',
log_format='"error %d"',
log_line_number=10,
hexify=True,
caller_func='func1',
caller_line=5,
file_name='f.c',
)
mgr1.save_all()
mgr1.close()
mgr2 = LogDBManager(data_dir=db_path, sources=sources)
result, log_id = mgr2.add_log(
source='BLE_HOST',
log_tag='APPL_TRACE_ERROR',
log_format='"error %d"',
log_line_number=10,
hexify=True,
caller_func='func1',
caller_line=5,
file_name='f.c',
)
self.assertEqual(result, LogDBManager.LOG_EXISTS)
self.assertEqual(log_id, 1)
mgr2.close()
finally:
shutil.rmtree(tmp, ignore_errors=True)
class TestFileProcessing(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.mkdtemp()
self.db = LogDBManager(data_dir=f'{self.tmp}/db', sources={'BLE_HOST': 'cfg_v1'})
def tearDown(self) -> None:
self.db.close()
shutil.rmtree(self.tmp, ignore_errors=True)
def _write(self, name: str, content: str) -> str:
import os
p = os.path.join(self.tmp, name)
with open(p, 'w') as f:
f.write(content)
return p
def test_new_file_not_processed(self) -> None:
src = self._write('src.c', 'void foo(void) {}')
comp = self._write('src.c.tmp', 'void foo(void) {}')
self.assertFalse(self.db.is_file_processed('BLE_HOST', src, comp))
def test_marked_file_is_processed(self) -> None:
src = self._write('src.c', 'void foo(void) {}')
comp = self._write('src.c.tmp', 'void foo_compressed(void) {}')
self.db.mark_file_processed('BLE_HOST', src, comp)
self.db.save_all()
self.assertTrue(self.db.is_file_processed('BLE_HOST', src, comp))
def test_modified_file_not_processed(self) -> None:
src = self._write('src.c', 'void foo(void) {}')
comp = self._write('src.c.tmp', 'void foo_compressed(void) {}')
self.db.mark_file_processed('BLE_HOST', src, comp)
self.db.save_all()
# Modify source
with open(src, 'w') as f:
f.write('void foo(void) { /* changed */ }')
self.assertFalse(self.db.is_file_processed('BLE_HOST', src, comp))
class TestConfigUpdate(unittest.TestCase):
def test_fresh_db_config_is_updated(self) -> None:
tmp = tempfile.mkdtemp()
try:
db = LogDBManager(data_dir=f'{tmp}/db', sources={'BLE_HOST': 'cfg_v1'})
self.assertTrue(db.is_config_updated('BLE_HOST'))
db.close()
finally:
shutil.rmtree(tmp, ignore_errors=True)
def test_same_config_not_updated_after_save_reload(self) -> None:
tmp = tempfile.mkdtemp()
try:
sources = {'BLE_HOST': 'cfg_v1'}
mgr1 = LogDBManager(data_dir=f'{tmp}/db', sources=sources)
mgr1.add_log(
source='BLE_HOST',
log_tag='T',
log_format='"m"',
log_line_number=1,
hexify=True,
caller_func='f',
caller_line=1,
file_name='x.c',
)
mgr1.save_all()
mgr1.close()
mgr2 = LogDBManager(data_dir=f'{tmp}/db', sources=sources)
self.assertFalse(mgr2.is_config_updated('BLE_HOST'))
mgr2.close()
finally:
shutil.rmtree(tmp, ignore_errors=True)
def test_different_config_is_updated(self) -> None:
tmp = tempfile.mkdtemp()
try:
mgr1 = LogDBManager(data_dir=f'{tmp}/db', sources={'BLE_HOST': 'cfg_v1'})
mgr1.add_log(
source='BLE_HOST',
log_tag='T',
log_format='"m"',
log_line_number=1,
hexify=True,
caller_func='f',
caller_line=1,
file_name='x.c',
)
mgr1.save_all()
mgr1.close()
mgr2 = LogDBManager(data_dir=f'{tmp}/db', sources={'BLE_HOST': 'cfg_v2_changed'})
self.assertTrue(mgr2.is_config_updated('BLE_HOST'))
mgr2.close()
finally:
shutil.rmtree(tmp, ignore_errors=True)
class TestSourceUpdateState(unittest.TestCase):
def test_fresh_db_returns_full(self) -> None:
tmp = tempfile.mkdtemp()
try:
db = LogDBManager(data_dir=f'{tmp}/db', sources={'BLE_HOST': 'cfg_v1'})
self.assertEqual(db.source_update_state('BLE_HOST'), LogDBManager.SOURCE_LOG_UPDATE_FULL)
db.close()
finally:
shutil.rmtree(tmp, ignore_errors=True)
def test_loaded_with_logs_no_new_returns_none(self) -> None:
tmp = tempfile.mkdtemp()
try:
sources = {'BLE_HOST': 'cfg_v1'}
mgr1 = LogDBManager(data_dir=f'{tmp}/db', sources=sources)
mgr1.add_log(
source='BLE_HOST',
log_tag='T',
log_format='"m"',
log_line_number=1,
hexify=True,
caller_func='f',
caller_line=1,
file_name='x.c',
)
mgr1.save_all()
mgr1.close()
mgr2 = LogDBManager(data_dir=f'{tmp}/db', sources=sources)
self.assertEqual(mgr2.source_update_state('BLE_HOST'), LogDBManager.SOURCE_LOG_UPDATE_NONE)
mgr2.close()
finally:
shutil.rmtree(tmp, ignore_errors=True)
def test_new_log_added_returns_partial(self) -> None:
tmp = tempfile.mkdtemp()
try:
sources = {'BLE_HOST': 'cfg_v1'}
mgr1 = LogDBManager(data_dir=f'{tmp}/db', sources=sources)
mgr1.add_log(
source='BLE_HOST',
log_tag='T',
log_format='"m1"',
log_line_number=1,
hexify=True,
caller_func='f',
caller_line=1,
file_name='x.c',
)
mgr1.save_all()
mgr1.close()
mgr2 = LogDBManager(data_dir=f'{tmp}/db', sources=sources)
mgr2.add_log(
source='BLE_HOST',
log_tag='T',
log_format='"m2"',
log_line_number=2,
hexify=True,
caller_func='g',
caller_line=2,
file_name='x.c',
)
self.assertEqual(mgr2.source_update_state('BLE_HOST'), LogDBManager.SOURCE_LOG_UPDATE_PARTIAL)
mgr2.close()
finally:
shutil.rmtree(tmp, ignore_errors=True)
def test_config_change_returns_full(self) -> None:
tmp = tempfile.mkdtemp()
try:
mgr1 = LogDBManager(data_dir=f'{tmp}/db', sources={'BLE_HOST': 'cfg_v1'})
mgr1.add_log(
source='BLE_HOST',
log_tag='T',
log_format='"m"',
log_line_number=1,
hexify=True,
caller_func='f',
caller_line=1,
file_name='x.c',
)
mgr1.save_all()
mgr1.close()
mgr2 = LogDBManager(data_dir=f'{tmp}/db', sources={'BLE_HOST': 'cfg_v2'})
self.assertEqual(mgr2.source_update_state('BLE_HOST'), LogDBManager.SOURCE_LOG_UPDATE_FULL)
mgr2.close()
finally:
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,200 @@
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
"""Tests for c_format_parse.py: FormatToken NamedTuple and parse_format_string."""
import unittest
import test_utils # noqa: F401 — must be first to set up sys.path
from c_format_parse import FormatToken
from c_format_parse import parse_format_string
class TestFormatTokenStructure(unittest.TestCase):
def test_is_tuple_subclass(self) -> None:
tokens = parse_format_string('%d')
self.assertIsInstance(tokens[0], tuple)
self.assertIsInstance(tokens[0], FormatToken)
def test_field_names(self) -> None:
self.assertEqual(
FormatToken._fields,
('start', 'end', 'full_spec', 'flags', 'width', 'precision', 'length', 'conv_char'),
)
def test_named_access_matches_index(self) -> None:
ft = parse_format_string('%08llx')[0]
self.assertEqual(ft.start, ft[0])
self.assertEqual(ft.end, ft[1])
self.assertEqual(ft.full_spec, ft[2])
self.assertEqual(ft.flags, ft[3])
self.assertEqual(ft.width, ft[4])
self.assertEqual(ft.precision, ft[5])
self.assertEqual(ft.length, ft[6])
self.assertEqual(ft.conv_char, ft[7])
def test_complex_token_fields(self) -> None:
ft = parse_format_string('%08llx')[0]
self.assertEqual(ft.flags, '0')
self.assertEqual(ft.width, '8')
self.assertEqual(ft.precision, '')
self.assertEqual(ft.length, 'll')
self.assertEqual(ft.conv_char, 'x')
self.assertEqual(ft.full_spec, '%08llx')
class TestBasicSpecifiers(unittest.TestCase):
def test_single_specifiers(self) -> None:
cases = [
('%d', 'd'),
('%i', 'i'),
('%u', 'u'),
('%x', 'x'),
('%X', 'X'),
('%o', 'o'),
('%c', 'c'),
('%s', 's'),
('%p', 'p'),
('%f', 'f'),
('%F', 'F'),
('%e', 'e'),
('%E', 'E'),
('%g', 'g'),
('%G', 'G'),
('%a', 'a'),
('%A', 'A'),
('%n', 'n'),
]
for fmt, conv in cases:
with self.subTest(fmt=fmt):
tokens = parse_format_string(fmt)
self.assertEqual(len(tokens), 1)
self.assertIsInstance(tokens[0], FormatToken)
self.assertEqual(tokens[0].conv_char, conv)
def test_escaped_percent(self) -> None:
tokens = parse_format_string('100%%')
self.assertTrue(all(isinstance(t, str) for t in tokens))
def test_empty_string(self) -> None:
self.assertEqual(parse_format_string(''), [])
def test_no_specifiers(self) -> None:
tokens = parse_format_string('hello world')
self.assertEqual(len(tokens), 1)
self.assertEqual(tokens[0], 'hello world')
class TestPositionTracking(unittest.TestCase):
def test_start_end_single(self) -> None:
ft = parse_format_string('%d')[0]
self.assertEqual(ft.start, 0)
self.assertEqual(ft.end, 2)
def test_start_end_with_prefix(self) -> None:
tokens = parse_format_string('abc%dxyz')
ft = [t for t in tokens if isinstance(t, FormatToken)][0]
self.assertEqual(ft.start, 3)
self.assertEqual(ft.end, 5)
def test_multiple_positions(self) -> None:
tokens = parse_format_string('%d %s')
fts = [t for t in tokens if isinstance(t, FormatToken)]
self.assertEqual(fts[0].start, 0)
self.assertEqual(fts[0].end, 2)
self.assertEqual(fts[1].start, 3)
self.assertEqual(fts[1].end, 5)
class TestLengthModifiers(unittest.TestCase):
def test_length_modifiers(self) -> None:
cases = [
('%hd', 'h', 'd'),
('%hhd', 'hh', 'd'),
('%ld', 'l', 'd'),
('%lld', 'll', 'd'),
('%zu', 'z', 'u'),
('%jd', 'j', 'd'),
('%td', 't', 'd'),
('%llx', 'll', 'x'),
('%lx', 'l', 'x'),
('%hu', 'h', 'u'),
]
for fmt, length, conv in cases:
with self.subTest(fmt=fmt):
ft = parse_format_string(fmt)[0]
self.assertEqual(ft.length, length)
self.assertEqual(ft.conv_char, conv)
class TestWidthAndPrecision(unittest.TestCase):
def test_fixed_width(self) -> None:
ft = parse_format_string('%10d')[0]
self.assertEqual(ft.width, '10')
self.assertEqual(ft.precision, '')
def test_fixed_precision(self) -> None:
ft = parse_format_string('%.2f')[0]
self.assertEqual(ft.width, '')
self.assertEqual(ft.precision, '2')
def test_width_and_precision(self) -> None:
ft = parse_format_string('%10.2f')[0]
self.assertEqual(ft.width, '10')
self.assertEqual(ft.precision, '2')
def test_dynamic_width(self) -> None:
ft = parse_format_string('%*d')[0]
self.assertEqual(ft.width, '*')
def test_dynamic_precision(self) -> None:
ft = parse_format_string('%.*s')[0]
self.assertEqual(ft.precision, '*')
def test_both_dynamic(self) -> None:
ft = parse_format_string('%*.*f')[0]
self.assertEqual(ft.width, '*')
self.assertEqual(ft.precision, '*')
class TestFlags(unittest.TestCase):
def test_flags(self) -> None:
cases = [
('%-d', '-'),
('%+d', '+'),
('% d', ' '),
('%#x', '#'),
('%0d', '0'),
('%08x', '0'),
]
for fmt, expected_flags in cases:
with self.subTest(fmt=fmt):
ft = parse_format_string(fmt)[0]
self.assertEqual(ft.flags, expected_flags)
class TestMixedContent(unittest.TestCase):
def test_text_and_specifiers(self) -> None:
tokens = parse_format_string('val=%d name=%s')
fts = [t for t in tokens if isinstance(t, FormatToken)]
strs = [t for t in tokens if isinstance(t, str)]
self.assertEqual(len(fts), 2)
self.assertEqual(fts[0].conv_char, 'd')
self.assertEqual(fts[1].conv_char, 's')
self.assertTrue(any('val=' in s for s in strs))
def test_many_specifiers(self) -> None:
tokens = parse_format_string('%d %s %lld %f %x')
fts = [t for t in tokens if isinstance(t, FormatToken)]
self.assertEqual(len(fts), 5)
self.assertEqual([ft.conv_char for ft in fts], ['d', 's', 'd', 'f', 'x'])
self.assertEqual(fts[2].length, 'll')
def test_quoted_format_string(self) -> None:
tokens = parse_format_string('"hello %d world"')
fts = [t for t in tokens if isinstance(t, FormatToken)]
self.assertEqual(len(fts), 1)
self.assertEqual(fts[0].conv_char, 'd')
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,162 @@
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
"""Tests for incremental compression: caching, re-runs, config changes."""
import shutil
import tempfile
import unittest
import test_utils # noqa: F401 — must be first to set up sys.path
import yaml
from LogDBManager import LogDBManager
from test_utils import PipelineContext
class _IncrementalTestCase(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.mkdtemp()
self.ctx = PipelineContext(self.tmp)
def tearDown(self) -> None:
self.ctx.close()
shutil.rmtree(self.tmp, ignore_errors=True)
class TestFirstRun(_IncrementalTestCase):
def test_all_files_compressed(self) -> None:
rel = self.ctx.copy_fixture('simple_logs.c')
macros = self.ctx.run_compression([rel])
self.assertIn('BLE_HOST', macros)
self.assertGreater(len(macros['BLE_HOST']), 0)
def test_header_generated(self) -> None:
rel = self.ctx.copy_fixture('simple_logs.c')
self.ctx.run_compression([rel])
self.assertTrue(self.ctx.header_path.exists())
class TestRerunUnchanged(_IncrementalTestCase):
def test_file_hashes_tracked(self) -> None:
"""After first run, is_file_processed returns True for unchanged files."""
rel = self.ctx.copy_fixture('simple_logs.c')
self.ctx.run_compression([rel])
# Simulate new build: close and reopen DB
self.ctx.db_manager.close()
db_path = self.ctx.build_dir / self.ctx.compressor.config.get('db_path', 'ble_log/ble_log_database')
self.ctx.db_manager = LogDBManager(
data_dir=str(db_path),
sources={s: str(c) for s, c in self.ctx.compressor.module_info.items()},
)
self.ctx.compressor.db_manager = self.ctx.db_manager
src_path = self.ctx.code_base / rel
temp_path = str(self.ctx.compressed_srcs / rel) + '.tmp'
self.assertTrue(self.ctx.db_manager.is_file_processed('BLE_HOST', src_path, temp_path))
def test_log_ids_unchanged_on_rerun(self) -> None:
"""Re-running compression on the same file produces same IDs."""
rel = self.ctx.copy_fixture('simple_logs.c')
macros1 = self.ctx.run_compression([rel])
ids1 = sorted(log_id for log_id, _ in macros1['BLE_HOST'])
# Re-run: same file, same content — macros for already-existing logs
macros2 = self.ctx.run_compression([rel])
ids2 = sorted(log_id for log_id, _ in macros2['BLE_HOST'])
self.assertEqual(ids1, ids2)
class TestRerunModifiedFile(_IncrementalTestCase):
def test_modified_file_reprocessed(self) -> None:
rel = self.ctx.copy_fixture('simple_logs.c')
self.ctx.run_compression([rel])
# Modify the source file
src_path = self.ctx.code_base / rel
content = src_path.read_text()
content += '\nvoid new_func(int v) { APPL_TRACE_DEBUG("new %d", v); }\n'
src_path.write_text(content)
macros = self.ctx.run_compression([rel])
self.assertIn('BLE_HOST', macros)
self.assertGreater(len(macros['BLE_HOST']), 0)
def test_existing_ids_stable(self) -> None:
rel = self.ctx.copy_fixture('simple_logs.c')
macros1 = self.ctx.run_compression([rel])
ids1 = sorted(log_id for log_id, _ in macros1['BLE_HOST'])
# Modify source: add a new log
src_path = self.ctx.code_base / rel
content = src_path.read_text()
content += '\nvoid extra(int v) { APPL_TRACE_DEBUG("extra %d", v); }\n'
src_path.write_text(content)
# Re-run — original IDs should still appear
macros2 = self.ctx.run_compression([rel])
ids2 = sorted(log_id for log_id, _ in macros2['BLE_HOST'])
for old_id in ids1:
self.assertIn(old_id, ids2)
class TestAddNewFile(_IncrementalTestCase):
def test_new_file_ids_continue(self) -> None:
rel1 = self.ctx.copy_fixture('simple_logs.c')
macros1 = self.ctx.run_compression([rel1])
ids1 = set(log_id for log_id, _ in macros1['BLE_HOST'])
max_id_1 = max(ids1)
# Simulate new build
self.ctx.db_manager.close()
db_path = self.ctx.build_dir / self.ctx.compressor.config.get('db_path', 'ble_log/ble_log_database')
self.ctx.db_manager = LogDBManager(
data_dir=str(db_path),
sources={s: str(c) for s, c in self.ctx.compressor.module_info.items()},
)
self.ctx.compressor.db_manager = self.ctx.db_manager
rel2 = self.ctx.copy_fixture('multi_level.c')
macros2 = self.ctx.run_compression([rel1, rel2])
if 'BLE_HOST' in macros2 and macros2['BLE_HOST']:
all_ids_2 = set(log_id for log_id, _ in macros2['BLE_HOST'])
new_ids = all_ids_2 - ids1
self.assertGreater(len(new_ids), 0)
for new_id in new_ids:
self.assertGreater(new_id, max_id_1)
class TestConfigChange(_IncrementalTestCase):
def test_config_change_triggers_full_regen(self) -> None:
rel = self.ctx.copy_fixture('simple_logs.c')
self.ctx.run_compression([rel])
# Change config
with open(self.ctx.config_path) as f:
config = yaml.safe_load(f)
config['log_config']['modules']['BLE_HOST']['tags'].append('NEW_TAG_ADDED')
with open(self.ctx.config_path, 'w') as f:
yaml.safe_dump(config, f)
# Reload
self.ctx.compressor.module_info.clear()
self.ctx.compressor.module_mod.clear()
self.ctx.compressor.load_config(str(self.ctx.config_path), ['BLE_HOST'])
self.ctx.db_manager.close()
db_path = self.ctx.build_dir / self.ctx.compressor.config.get('db_path', 'ble_log/ble_log_database')
self.ctx.db_manager = LogDBManager(
data_dir=str(db_path),
sources={s: str(c) for s, c in self.ctx.compressor.module_info.items()},
)
self.ctx.compressor.db_manager = self.ctx.db_manager
self.assertTrue(self.ctx.db_manager.is_config_updated('BLE_HOST'))
self.assertEqual(
self.ctx.db_manager.source_update_state('BLE_HOST'),
LogDBManager.SOURCE_LOG_UPDATE_FULL,
)
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,149 @@
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
"""Tests for LogCompressor.extract_log_calls: tree-sitter AST extraction."""
import unittest
import test_utils # noqa: F401 — must be first to set up sys.path
from c_format_parse import FormatToken
from test_utils import make_compressor
TAGS = [
'APPL_TRACE_ERROR',
'APPL_TRACE_WARNING',
'APPL_TRACE_API',
'APPL_TRACE_DEBUG',
'APPL_TRACE_EVENT',
'APPL_TRACE_VERBOSE',
]
class TestExtractNoLogs(unittest.TestCase):
def setUp(self) -> None:
self.comp = make_compressor()
def test_no_matching_tags(self) -> None:
code = b'void foo(void) { printf("hello"); }'
logs = self.comp.extract_log_calls(code, TAGS)
self.assertEqual(logs, [])
def test_file_with_no_functions(self) -> None:
code = b'int global_var = 42;'
logs = self.comp.extract_log_calls(code, TAGS)
self.assertEqual(logs, [])
class TestExtractBasicLogs(unittest.TestCase):
def setUp(self) -> None:
self.comp = make_compressor()
def test_single_log(self) -> None:
code = b'void foo(void) { APPL_TRACE_DEBUG("msg %d", val); }'
logs = self.comp.extract_log_calls(code, TAGS)
self.assertEqual(len(logs), 1)
self.assertEqual(logs[0]['tag'][0], 'APPL_TRACE_DEBUG')
self.assertEqual(logs[0]['caller_name'], 'foo')
def test_no_arg_log(self) -> None:
code = b'void bar(void) { APPL_TRACE_DEBUG("simple message"); }'
logs = self.comp.extract_log_calls(code, TAGS)
self.assertEqual(len(logs), 1)
tokens = [t for t in logs[0]['argu_tokens'] if isinstance(t, FormatToken)]
self.assertEqual(len(tokens), 0)
def test_multiple_tags(self) -> None:
code = b'void foo(void) {\n APPL_TRACE_DEBUG("debug");\n APPL_TRACE_ERROR("error %d", code);\n}\n'
logs = self.comp.extract_log_calls(code, TAGS)
self.assertEqual(len(logs), 2)
self.assertEqual(logs[0]['tag'][0], 'APPL_TRACE_DEBUG')
self.assertEqual(logs[1]['tag'][0], 'APPL_TRACE_ERROR')
class TestExtractFormatTokens(unittest.TestCase):
def setUp(self) -> None:
self.comp = make_compressor()
def test_format_tokens_populated(self) -> None:
code = b'void foo(void) { APPL_TRACE_DEBUG("val %d str %s", a, b); }'
logs = self.comp.extract_log_calls(code, TAGS)
tokens = [t for t in logs[0]['argu_tokens'] if isinstance(t, FormatToken)]
self.assertEqual(len(tokens), 2)
self.assertEqual(tokens[0].conv_char, 'd')
self.assertEqual(tokens[1].conv_char, 's')
def test_hexify_true_for_normal_formats(self) -> None:
code = b'void foo(void) { APPL_TRACE_DEBUG("val %d", a); }'
logs = self.comp.extract_log_calls(code, TAGS)
self.assertTrue(logs[0]['hexify'])
def test_hexify_false_for_dynamic_width(self) -> None:
code = b'void foo(void) { APPL_TRACE_DEBUG("%*d", w, a); }'
logs = self.comp.extract_log_calls(code, TAGS)
self.assertEqual(len(logs), 0)
def test_hexify_false_for_dynamic_precision(self) -> None:
code = b'void foo(void) { APPL_TRACE_DEBUG("%.*s", p, s); }'
logs = self.comp.extract_log_calls(code, TAGS)
self.assertEqual(len(logs), 0)
class TestExtractSpecialTokens(unittest.TestCase):
def setUp(self) -> None:
self.comp = make_compressor()
def test_func_macro_replaced(self) -> None:
code = b'void foo(void) { APPL_TRACE_DEBUG("%s starting", __func__); }'
logs = self.comp.extract_log_calls(code, TAGS)
self.assertEqual(len(logs), 1)
tokens = [t for t in logs[0]['argu_tokens'] if isinstance(t, FormatToken)]
self.assertTrue(any(t.conv_char == '@func' for t in tokens))
def test_line_macro_replaced(self) -> None:
code = b'void foo(void) { APPL_TRACE_DEBUG("at line %d", __LINE__); }'
logs = self.comp.extract_log_calls(code, TAGS)
self.assertEqual(len(logs), 1)
tokens = [t for t in logs[0]['argu_tokens'] if isinstance(t, FormatToken)]
self.assertTrue(any(t.conv_char == '@line' for t in tokens))
def test_mac2str_replaced(self) -> None:
code = b'void foo(void) { APPL_TRACE_DEBUG("addr=" MACSTR, MAC2STR(bd_addr)); }'
logs = self.comp.extract_log_calls(code, TAGS)
self.assertEqual(len(logs), 1)
tokens = [t for t in logs[0]['argu_tokens'] if isinstance(t, FormatToken)]
hex_tokens = [t for t in tokens if t.conv_char.startswith('@hex_func')]
self.assertEqual(len(hex_tokens), 1)
self.assertIn('bd_addr', hex_tokens[0].conv_char)
self.assertIn('6', hex_tokens[0].conv_char)
class TestExtractCallerInfo(unittest.TestCase):
def setUp(self) -> None:
self.comp = make_compressor()
def test_correct_caller(self) -> None:
code = (
b'void first_func(void) {\n'
b' APPL_TRACE_DEBUG("in first");\n'
b'}\n'
b'void second_func(void) {\n'
b' APPL_TRACE_DEBUG("in second");\n'
b'}\n'
)
logs = self.comp.extract_log_calls(code, TAGS)
self.assertEqual(len(logs), 2)
self.assertEqual(logs[0]['caller_name'], 'first_func')
self.assertEqual(logs[1]['caller_name'], 'second_func')
class TestExtractArgMismatch(unittest.TestCase):
def setUp(self) -> None:
self.comp = make_compressor()
def test_arg_count_mismatch_raises(self) -> None:
code = b'void foo(void) { APPL_TRACE_DEBUG("msg %d %d", val); }'
with self.assertRaises(SyntaxError):
self.comp.extract_log_calls(code, TAGS)
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,159 @@
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
"""Tests for compressed macro generation (bluedroid and mesh module scripts)."""
import sys
import types
import unittest
from test_utils import SCRIPTS_DIR
class TestBluedroidMacroGen(unittest.TestCase):
mod: types.ModuleType
@classmethod
def setUpClass(cls) -> None:
p = str(SCRIPTS_DIR / 'module_scripts' / 'bluedroid')
if p not in sys.path:
sys.path.insert(0, p)
import make_bluedroid_log_macro
cls.mod = make_bluedroid_log_macro
def test_header_head_has_declarations(self) -> None:
head = self.mod.gen_header_head()
self.assertIn('ble_log_compressed_hex_print', head)
self.assertIn('ble_log_compressed_hex_print_buf', head)
self.assertIn('extern', head)
def test_zero_args_macro(self) -> None:
stmt = self.mod.gen_compressed_stmt(
log_index=1,
module_id=0,
func_name='APPL_TRACE_DEBUG',
fmt=None,
args=[],
buffer_args=[],
)
self.assertIn('ble_log_compressed_hex_print(0,1, 0)', stmt)
def test_single_arg_macro(self) -> None:
stmt = self.mod.gen_compressed_stmt(
log_index=2,
module_id=0,
func_name='APPL_TRACE_ERROR',
fmt=None,
args=[{'name': 'val', 'size_type': '0'}],
buffer_args=[],
)
self.assertIn('ble_log_compressed_hex_print(0,2, 1, 0, val)', stmt)
def test_multi_arg_with_sizes(self) -> None:
stmt = self.mod.gen_compressed_stmt(
log_index=3,
module_id=0,
func_name='APPL_TRACE_DEBUG',
fmt=None,
args=[
{'name': 'a', 'size_type': '0'},
{'name': 'b', 'size_type': '1'},
{'name': 'c', 'size_type': '2'},
],
buffer_args=[],
)
self.assertIn('3, 0, 1, 2, a, b, c', stmt)
def test_appl_error_level_check(self) -> None:
stmt = self.mod.gen_compressed_stmt(
log_index=1,
module_id=0,
func_name='APPL_TRACE_ERROR',
fmt=None,
args=[],
buffer_args=[],
)
self.assertIn('appl_trace_level >= BT_TRACE_LEVEL_ERROR', stmt)
self.assertIn('BT_LOG_LEVEL_CHECK(APPL, ERROR)', stmt)
def test_appl_debug_level_check(self) -> None:
stmt = self.mod.gen_compressed_stmt(
log_index=1,
module_id=0,
func_name='APPL_TRACE_DEBUG',
fmt=None,
args=[],
buffer_args=[],
)
self.assertIn('appl_trace_level >= BT_TRACE_LEVEL_DEBUG', stmt)
self.assertIn('BT_LOG_LEVEL_CHECK(APPL, DEBUG)', stmt)
def test_buffer_args_generate_buf_print(self) -> None:
stmt = self.mod.gen_compressed_stmt(
log_index=5,
module_id=0,
func_name='APPL_TRACE_DEBUG',
fmt=None,
args=[],
buffer_args=[{'buffer': 'bd_addr', 'length': '6'}],
)
self.assertIn('ble_log_compressed_hex_print_buf', stmt)
self.assertIn('(const uint8_t *)bd_addr, 6', stmt)
def test_multiple_buffer_args(self) -> None:
stmt = self.mod.gen_compressed_stmt(
log_index=6,
module_id=0,
func_name='APPL_TRACE_DEBUG',
fmt=None,
args=[],
buffer_args=[
{'buffer': 'buf1', 'length': '10'},
{'buffer': 'buf2', 'length': '20'},
],
)
self.assertIn('(const uint8_t *)buf1', stmt)
self.assertIn('(const uint8_t *)buf2', stmt)
class TestMeshMacroGen(unittest.TestCase):
mod: types.ModuleType
@classmethod
def setUpClass(cls) -> None:
p = str(SCRIPTS_DIR / 'module_scripts' / 'ble_mesh')
if p not in sys.path:
sys.path.insert(0, p)
import make_mesh_log_macro
cls.mod = make_mesh_log_macro
def test_header_head_has_declarations(self) -> None:
head = self.mod.gen_header_head()
self.assertIn('ble_log_compressed_hex_print', head)
def test_bt_err_level_check(self) -> None:
stmt = self.mod.gen_compressed_stmt(
log_index=1,
module_id=1,
func_name='BT_ERR',
fmt=None,
args=[],
buffer_args=[],
)
self.assertIn('ERROR', stmt)
def test_bt_dbg_level_check(self) -> None:
stmt = self.mod.gen_compressed_stmt(
log_index=2,
module_id=1,
func_name='BT_DBG',
fmt=None,
args=[],
buffer_args=[],
)
self.assertIn('DEBUG', stmt)
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,161 @@
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
"""End-to-end pipeline tests: .c input -> .h output with golden file comparison."""
import re
import shutil
import tempfile
import unittest
from test_utils import FIXTURE_DIR
from test_utils import PipelineContext
from test_utils import assert_header_matches_golden
class _E2ETestCase(unittest.TestCase):
"""Base class with pipeline setUp/tearDown."""
def setUp(self) -> None:
self.tmp = tempfile.mkdtemp()
self.ctx = PipelineContext(self.tmp)
def tearDown(self) -> None:
self.ctx.close()
shutil.rmtree(self.tmp, ignore_errors=True)
class TestSimpleLogsE2E(_E2ETestCase):
def test_compresses_and_generates_header(self) -> None:
rel = self.ctx.copy_fixture('simple_logs.c')
macros = self.ctx.run_compression([rel])
self.assertIn('BLE_HOST', macros)
self.assertEqual(len(macros['BLE_HOST']), 4)
self.assertTrue(self.ctx.header_path.exists())
def test_tag_replacement_in_source(self) -> None:
rel = self.ctx.copy_fixture('simple_logs.c')
self.ctx.run_compression([rel])
tmp_file = self.ctx.compressed_srcs / 'test_src' / 'simple_logs.c.tmp'
content = tmp_file.read_text()
self.assertRegex(content, r'APPL_TRACE_DEBUG_\d+')
self.assertRegex(content, r'APPL_TRACE_ERROR_\d+')
def test_header_structure(self) -> None:
rel = self.ctx.copy_fixture('simple_logs.c')
self.ctx.run_compression([rel])
header = self.ctx.header_path.read_text()
self.assertIn('#ifndef __BLE_HOST_INTERNAL_LOG_INDEX_H', header)
self.assertIn('#define __BLE_HOST_INTERNAL_LOG_INDEX_H', header)
self.assertIn('#endif', header)
self.assertIn('ble_log_compressed_hex_print', header)
def test_macros_sorted_by_id(self) -> None:
rel = self.ctx.copy_fixture('simple_logs.c')
self.ctx.run_compression([rel])
header = self.ctx.header_path.read_text()
ids = [int(m) for m in re.findall(r'#define \w+_(\d+)\(fmt', header)]
self.assertEqual(ids, sorted(ids))
def test_preserve_tag_appends_original(self) -> None:
rel = self.ctx.copy_fixture('simple_logs.c')
self.ctx.run_compression([rel])
header = self.ctx.header_path.read_text()
self.assertIn('APPL_TRACE_ERROR(fmt, ##__VA_ARGS__)', header)
self.assertIn('APPL_TRACE_WARNING(fmt, ##__VA_ARGS__)', header)
self.assertNotIn('APPL_TRACE_DEBUG(fmt, ##__VA_ARGS__)', header)
self.assertNotIn('APPL_TRACE_API(fmt, ##__VA_ARGS__)', header)
class TestFormatSpecifiersE2E(_E2ETestCase):
def test_all_size_types_in_header(self) -> None:
rel = self.ctx.copy_fixture('format_specifiers.c')
self.ctx.run_compression([rel])
header = self.ctx.header_path.read_text()
self.assertTrue(', 0,' in header or ', 0, ' in header) # U32
self.assertTrue(', 1,' in header or ', 1, ' in header) # STR
self.assertTrue(', 2,' in header or ', 2, ' in header) # U64
class TestSpecialTokensE2E(_E2ETestCase):
def test_func_and_line_in_header(self) -> None:
rel = self.ctx.copy_fixture('special_tokens.c')
self.ctx.run_compression([rel])
header = self.ctx.header_path.read_text()
self.assertIn('ble_log_compressed_hex_print_buf', header)
def test_func_macro_not_in_args(self) -> None:
rel = self.ctx.copy_fixture('special_tokens.c')
self.ctx.run_compression([rel])
header = self.ctx.header_path.read_text()
self.assertNotIn('__func__', header)
class TestNoLogsE2E(_E2ETestCase):
def test_no_macros_generated(self) -> None:
rel = self.ctx.copy_fixture('no_logs.c')
macros = self.ctx.run_compression([rel])
self.assertEqual(len(macros), 0)
class TestMultiLevelE2E(_E2ETestCase):
def test_all_levels_get_unique_ids(self) -> None:
rel = self.ctx.copy_fixture('multi_level.c')
macros = self.ctx.run_compression([rel])
ids = [log_id for log_id, _ in macros['BLE_HOST']]
self.assertEqual(len(ids), len(set(ids)))
def test_different_level_checks(self) -> None:
rel = self.ctx.copy_fixture('multi_level.c')
self.ctx.run_compression([rel])
header = self.ctx.header_path.read_text()
self.assertIn('BT_TRACE_LEVEL_ERROR', header)
self.assertIn('BT_TRACE_LEVEL_WARNING', header)
self.assertIn('BT_TRACE_LEVEL_API', header)
self.assertIn('BT_TRACE_LEVEL_DEBUG', header)
class TestMultiFileE2E(_E2ETestCase):
def test_ids_globally_unique(self) -> None:
rel1 = self.ctx.copy_fixture('simple_logs.c')
rel2 = self.ctx.copy_fixture('multi_level.c')
macros = self.ctx.run_compression([rel1, rel2])
all_ids = [log_id for log_id, _ in macros['BLE_HOST']]
self.assertEqual(len(all_ids), len(set(all_ids)))
self.assertEqual(len(all_ids), 10)
class TestConcatenatedStringsE2E(_E2ETestCase):
def test_pri_macros_produce_u64(self) -> None:
rel = self.ctx.copy_fixture('concatenated_strings.c')
self.ctx.run_compression([rel])
header = self.ctx.header_path.read_text()
self.assertTrue(', 2,' in header or ', 2, ' in header)
class TestGoldenFileComparison(_E2ETestCase):
def _run_and_compare(self, fixture_files: list, golden_name: str) -> None:
rels = [self.ctx.copy_fixture(f) for f in fixture_files]
self.ctx.run_compression(rels)
golden = FIXTURE_DIR / 'expected' / golden_name
if not golden.exists():
self.skipTest(f'Golden file {golden_name} not found. Run update_golden.py first.')
assert_header_matches_golden(self, self.ctx.header_path, golden)
def test_simple_logs_golden(self) -> None:
self._run_and_compare(['simple_logs.c'], 'simple_logs_index.h')
def test_format_specifiers_golden(self) -> None:
self._run_and_compare(['format_specifiers.c'], 'format_specifiers_index.h')
def test_special_tokens_golden(self) -> None:
self._run_and_compare(['special_tokens.c'], 'special_tokens_index.h')
def test_multi_level_golden(self) -> None:
self._run_and_compare(['multi_level.c'], 'multi_level_index.h')
def test_multi_file_golden(self) -> None:
self._run_and_compare(['simple_logs.c', 'multi_level.c'], 'multi_file_index.h')
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,203 @@
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
"""Shared utilities for BLE log compression tests (unittest-based, no third-party deps)."""
from __future__ import annotations
import difflib
import os
import re
import shutil
import sys
import unittest
from pathlib import Path
from typing import Any
# Add scripts/ to sys.path so test modules can import the production code
SCRIPTS_DIR = Path(__file__).resolve().parent.parent / 'scripts'
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
FIXTURE_DIR = Path(__file__).resolve().parent / 'fixtures'
BLUEDROID_SCRIPT = SCRIPTS_DIR / 'module_scripts' / 'bluedroid' / 'make_bluedroid_log_macro.py'
MESH_SCRIPT = SCRIPTS_DIR / 'module_scripts' / 'ble_mesh' / 'make_mesh_log_macro.py'
BLUEDROID_TAGS = [
'APPL_TRACE_ERROR',
'APPL_TRACE_WARNING',
'APPL_TRACE_API',
'APPL_TRACE_DEBUG',
'APPL_TRACE_EVENT',
'APPL_TRACE_VERBOSE',
]
MESH_TAGS = [
'BT_ERR',
'BT_WARN',
'BT_INFO',
'BT_DBG',
'NET_BUF_ERR',
'NET_BUF_WARN',
'NET_BUF_DBG',
]
def make_db_manager(tmp_dir: str) -> Any:
"""Create a fresh LogDBManager in tmp_dir."""
from LogDBManager import LogDBManager
db_path = os.path.join(tmp_dir, 'db')
return LogDBManager(data_dir=db_path, sources={'BLE_HOST': 'test_config_v1'})
def make_compressor() -> Any:
"""Create a LogCompressor with parser initialized."""
from ble_log_compress import LogCompressor
c = LogCompressor()
c.init_parser()
return c
def write_yaml_config(
tmp_dir: str,
tags: list[str],
script_path: str | Path,
module_name: str = 'BLE_HOST',
log_index_file: str = 'test_log_index.h',
tags_with_preserve: list[str] | None = None,
) -> Path:
"""Write a module_info.yml and return its path."""
import yaml
tags_with_preserve = tags_with_preserve or []
config = {
'log_config': {
'db_path': 'ble_log/ble_log_database',
'modules': {
module_name: {
'description': f'{module_name} Test',
'code_path': ['test_src'],
'log_index_file': log_index_file,
'script': str(script_path),
'tags': tags,
'tags_with_preserve': tags_with_preserve,
}
},
}
}
config_dir = Path(tmp_dir) / 'build' / 'ble_log'
config_dir.mkdir(parents=True, exist_ok=True)
config_path = config_dir / 'module_info.yml'
with open(config_path, 'w') as f:
yaml.safe_dump(config, f)
return config_path
class PipelineContext:
"""Full pipeline context: directory structure + config + DB + compressor."""
def __init__(self, tmp_dir: str) -> None:
from ble_log_compress import LogCompressor
from LogDBManager import LogDBManager
self.tmp_dir = Path(tmp_dir)
self.code_base = self.tmp_dir / 'code_base'
self.build_dir = self.tmp_dir / 'build'
self.compressed_srcs = self.build_dir / 'ble_log' / '.compressed_srcs'
(self.build_dir / 'ble_log' / 'include').mkdir(parents=True, exist_ok=True)
self.compressed_srcs.mkdir(parents=True, exist_ok=True)
self.code_base.mkdir(parents=True, exist_ok=True)
self.config_path = write_yaml_config(
tmp_dir,
tags=BLUEDROID_TAGS,
script_path=BLUEDROID_SCRIPT,
tags_with_preserve=['APPL_TRACE_ERROR', 'APPL_TRACE_WARNING'],
)
self.compressor = LogCompressor()
self.compressor.code_base_path = self.code_base
self.compressor.build_dir = self.build_dir
self.compressor.bt_compressed_srcs_path = self.compressed_srcs
self.compressor.load_config(str(self.config_path), ['BLE_HOST'])
db_path = self.build_dir / self.compressor.config.get('db_path', 'ble_log/ble_log_database')
self.db_manager = LogDBManager(
data_dir=str(db_path),
sources={s: str(c) for s, c in self.compressor.module_info.items()},
)
self.compressor.db_manager = self.db_manager
self.module = 'BLE_HOST'
def copy_fixture(self, filename: str) -> str:
"""Copy a fixture C file into the code base and return its relative path."""
src = FIXTURE_DIR / 'c_sources' / filename
rel_path = Path('test_src') / filename
dst = self.code_base / rel_path
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
return str(rel_path)
def run_compression(self, src_list: list[str]) -> dict[str, list[tuple[int, str]]]:
"""Run prepare + compress + header generation. Returns generated macros."""
self.compressor.prepare_source_files(src_list)
files_to_process = []
for module, info in self.compressor.module_info.items():
files_to_process.extend([(module, path) for path in info['files_to_process']])
files_to_process.sort(key=lambda x: x[1])
all_macros: dict[str, list[tuple[int, str]]] = {}
for file_info in files_to_process:
file_macros = self.compressor.compress_file(file_info)
for module, log_id, macro in file_macros:
all_macros.setdefault(module, []).append((log_id, macro))
for module, macros in all_macros.items():
self.compressor.generate_log_index_header(module, macros)
# Mark files as processed
for module, info in self.compressor.module_info.items():
for temp_path in info['files_to_process']:
src_path = self.code_base / os.path.relpath(temp_path[:-4], self.compressed_srcs)
self.db_manager.mark_file_processed(module, src_path, temp_path)
# Copy .tmp to final
for root, _, files in os.walk(self.compressed_srcs):
for name in files:
if name.endswith('.tmp'):
file_src = os.path.join(root, name)
dst_path = os.path.join(root, name[:-4])
shutil.copy2(file_src, dst_path)
self.db_manager.save_all()
return all_macros
@property
def header_path(self) -> Path:
return self.build_dir / 'ble_log' / 'include' / 'test_log_index.h'
def close(self) -> None:
self.db_manager.close()
def assert_header_matches_golden(
test_case: unittest.TestCase,
generated_path: str | Path,
golden_path: str | Path,
) -> None:
"""Compare generated header to golden file, normalizing copyright year."""
gen_text = Path(generated_path).read_text()
gold_text = Path(golden_path).read_text()
gen_norm = re.sub(r'20\d{2}', 'YYYY', gen_text)
gold_norm = re.sub(r'20\d{2}', 'YYYY', gold_text)
if gen_norm != gold_norm:
diff = difflib.unified_diff(
gold_norm.splitlines(keepends=True),
gen_norm.splitlines(keepends=True),
fromfile='expected (golden)',
tofile='generated',
)
test_case.fail(f'Header mismatch:\n{"".join(diff)}')

View File

@@ -0,0 +1,144 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
"""
Regenerate golden expected output files from fixture C sources.
Usage:
python3 update_golden.py
This runs the compression pipeline on each fixture C source file and copies
the generated header to fixtures/expected/. The output should be reviewed
for correctness before committing.
"""
import shutil
import sys
import tempfile
from pathlib import Path
TESTS_DIR = Path(__file__).resolve().parent
SCRIPTS_DIR = TESTS_DIR.parent / 'scripts'
FIXTURE_DIR = TESTS_DIR / 'fixtures'
EXPECTED_DIR = FIXTURE_DIR / 'expected'
sys.path.insert(0, str(SCRIPTS_DIR))
import yaml # noqa: E402
from ble_log_compress import LogCompressor # noqa: E402
from LogDBManager import LogDBManager # noqa: E402
BLUEDROID_SCRIPT = SCRIPTS_DIR / 'module_scripts' / 'bluedroid' / 'make_bluedroid_log_macro.py'
BLUEDROID_TAGS = [
'APPL_TRACE_ERROR',
'APPL_TRACE_WARNING',
'APPL_TRACE_API',
'APPL_TRACE_DEBUG',
'APPL_TRACE_EVENT',
'APPL_TRACE_VERBOSE',
]
def run_pipeline(fixture_files: list[str], output_name: str, tags_with_preserve: list[str] | None = None) -> None:
"""Run compression on fixture files and save the generated header as a golden file."""
tags_with_preserve = tags_with_preserve or ['APPL_TRACE_ERROR', 'APPL_TRACE_WARNING']
with tempfile.TemporaryDirectory() as tmp_str:
tmp = Path(tmp_str)
code_base = tmp / 'code_base'
build_dir = tmp / 'build'
compressed_srcs = build_dir / 'ble_log' / '.compressed_srcs'
(build_dir / 'ble_log' / 'include').mkdir(parents=True)
compressed_srcs.mkdir(parents=True)
# Write config
config = {
'log_config': {
'db_path': 'ble_log/ble_log_database',
'modules': {
'BLE_HOST': {
'description': 'BLE Host Test',
'code_path': ['test_src'],
'log_index_file': 'test_log_index.h',
'script': str(BLUEDROID_SCRIPT),
'tags': BLUEDROID_TAGS,
'tags_with_preserve': tags_with_preserve,
}
},
}
}
config_path = build_dir / 'ble_log' / 'module_info.yml'
with open(config_path, 'w') as f:
yaml.safe_dump(config, f)
# Copy fixtures
rel_paths = []
for fname in fixture_files:
src = FIXTURE_DIR / 'c_sources' / fname
rel = Path('test_src') / fname
dst = code_base / rel
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
rel_paths.append(str(rel))
# Init compressor
comp = LogCompressor()
comp.code_base_path = code_base
comp.build_dir = build_dir
comp.bt_compressed_srcs_path = compressed_srcs
comp.load_config(str(config_path), ['BLE_HOST'])
db_path = build_dir / comp.config.get('db_path', 'ble_log/ble_log_database')
db = LogDBManager(
data_dir=str(db_path),
sources={s: str(c) for s, c in comp.module_info.items()},
)
comp.db_manager = db
# Run compression
comp.prepare_source_files(rel_paths)
files_to_process = []
for module, info in comp.module_info.items():
files_to_process.extend([(module, p) for p in info['files_to_process']])
files_to_process.sort(key=lambda x: x[1])
all_macros: dict[str, list[tuple[int, str]]] = {}
for fi in files_to_process:
for module, log_id, macro in comp.compress_file(fi):
all_macros.setdefault(module, []).append((log_id, macro))
for module, macros in all_macros.items():
comp.generate_log_index_header(module, macros)
db.close()
# Copy generated header to expected
header = build_dir / 'ble_log' / 'include' / 'test_log_index.h'
if header.exists():
dest = EXPECTED_DIR / output_name
shutil.copy2(header, dest)
print(f'Generated: {dest}')
else:
print(f'No header generated for {fixture_files}')
def main() -> None:
EXPECTED_DIR.mkdir(parents=True, exist_ok=True)
scenarios = [
(['simple_logs.c'], 'simple_logs_index.h'),
(['format_specifiers.c'], 'format_specifiers_index.h'),
(['special_tokens.c'], 'special_tokens_index.h'),
(['multi_level.c'], 'multi_level_index.h'),
(['simple_logs.c', 'multi_level.c'], 'multi_file_index.h'),
]
for files, output in scenarios:
run_pipeline(files, output)
print(f'\nAll golden files generated in {EXPECTED_DIR}')
print('Review the output for correctness before committing.')
if __name__ == '__main__':
main()