Add Server-Sent Events (SSE) and streaming response support with three streaming functions: - stream(): Basic binary streaming with chunked transfer encoding - stream_text(): Text streaming with proper Content-Type headers - stream_sse(): SSE event streaming with full event field support Features: - SSEEvent struct with data, event, id, retry fields - StreamContext for write, writeln, write_sse, sleep, pipe operations - Multi-line data handling in SSE events - Custom error handler support - Client disconnect detection via on_abort callback - Integrated with both Picoev and uSockets backends New files: - streaming.v: Core streaming implementation - examples/sse_demo.v: Comprehensive SSE demo with test page - tests/test_stream_*.v: 62 property-based tests Updated: - picoev_server.v: PicoevStreamWriter integration - usockets_server.v: UsocketsStreamWriter integration - README.md: SSE streaming documentation
435 lines
11 KiB
V
435 lines
11 KiB
V
// test_picoev_stream_writer_properties.v
|
|
// Property-Based Testing for PicoevStreamWriter chunked encoding
|
|
// Feature: sse-streaming-helper, Property 10: Chunked 编码格式正确性
|
|
// Validates: Requirements 6.2
|
|
module main
|
|
|
|
import rand
|
|
import time
|
|
|
|
const test_iterations = 100
|
|
|
|
struct PropertyTestStats {
|
|
mut:
|
|
total_tests int
|
|
passed_tests int
|
|
failed_tests int
|
|
}
|
|
|
|
fn (mut stats PropertyTestStats) run_property_test(test_name string, test_func fn () bool) {
|
|
stats.total_tests++
|
|
print('🔬 ${test_name}... ')
|
|
|
|
if test_func() {
|
|
stats.passed_tests++
|
|
println('✅')
|
|
} else {
|
|
stats.failed_tests++
|
|
println('❌')
|
|
}
|
|
}
|
|
|
|
fn (stats PropertyTestStats) print_summary() {
|
|
println('\n=== PicoevStreamWriter 属性测试总结 ===')
|
|
println('总测试数: ${stats.total_tests}')
|
|
println('通过: ${stats.passed_tests}')
|
|
println('失败: ${stats.failed_tests}')
|
|
|
|
if stats.failed_tests == 0 {
|
|
println('🎉 所有属性测试通过!')
|
|
} else {
|
|
println('⚠️ 有 ${stats.failed_tests} 个属性测试失败')
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Chunked Encoding Functions (copied from streaming.v for testing)
|
|
// ============================================================================
|
|
|
|
// format_chunk_size - Format the chunk size as hex followed by CRLF
|
|
@[inline]
|
|
fn format_chunk_size(size int) string {
|
|
return '${size:x}\r\n'
|
|
}
|
|
|
|
// format_chunk - Format a complete chunk with size, data, and trailing CRLF
|
|
fn format_chunk(data []u8) string {
|
|
if data.len == 0 {
|
|
return ''
|
|
}
|
|
return '${data.len:x}\r\n${data.bytestr()}\r\n'
|
|
}
|
|
|
|
// format_chunk_string - Format a string as a complete chunk
|
|
fn format_chunk_string(data string) string {
|
|
if data.len == 0 {
|
|
return ''
|
|
}
|
|
return '${data.len:x}\r\n${data}\r\n'
|
|
}
|
|
|
|
// format_final_chunk - Return the final chunk marker for chunked encoding
|
|
fn format_final_chunk() string {
|
|
return '0\r\n\r\n'
|
|
}
|
|
|
|
// ============================================================================
|
|
// Random Data Generators
|
|
// ============================================================================
|
|
|
|
// Generate random bytes for testing
|
|
fn generate_random_bytes(max_len int) []u8 {
|
|
len := rand.int_in_range(1, max_len) or { 10 }
|
|
mut data := []u8{len: len}
|
|
for i in 0 .. len {
|
|
data[i] = u8(rand.int_in_range(0, 256) or { 0 })
|
|
}
|
|
return data
|
|
}
|
|
|
|
// Generate random string for testing (printable ASCII only to avoid encoding issues)
|
|
fn generate_random_string(max_len int) string {
|
|
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.'
|
|
len := rand.int_in_range(1, max_len) or { 10 }
|
|
mut result := ''
|
|
for _ in 0 .. len {
|
|
idx := rand.int_in_range(0, chars.len) or { 0 }
|
|
result += chars[idx].ascii_str()
|
|
}
|
|
return result
|
|
}
|
|
|
|
// ============================================================================
|
|
// Chunk Parsing Helper
|
|
// ============================================================================
|
|
|
|
// Parse a chunked encoded string and extract the data
|
|
// Returns the extracted data and whether parsing was successful
|
|
fn parse_chunked_data(chunked string) !([]u8, bool) {
|
|
// Find the size part (before first \r\n)
|
|
mut crlf_pos := -1
|
|
for i := 0; i < chunked.len - 1; i++ {
|
|
if chunked[i] == `\r` && chunked[i + 1] == `\n` {
|
|
crlf_pos = i
|
|
break
|
|
}
|
|
}
|
|
|
|
if crlf_pos == -1 {
|
|
return error('No CRLF found in chunk header')
|
|
}
|
|
|
|
size_hex := chunked[..crlf_pos]
|
|
size := parse_hex(size_hex) or {
|
|
return error('Invalid hex size: ${size_hex}')
|
|
}
|
|
|
|
// Extract data after the first \r\n
|
|
data_start := crlf_pos + 2
|
|
data_end := data_start + size
|
|
|
|
if data_end > chunked.len {
|
|
return error('Chunk data incomplete')
|
|
}
|
|
|
|
data := chunked[data_start..data_end].bytes()
|
|
|
|
// Verify trailing \r\n
|
|
if data_end + 2 > chunked.len {
|
|
return error('Missing trailing CRLF')
|
|
}
|
|
|
|
trailing := chunked[data_end..data_end + 2]
|
|
if trailing != '\r\n' {
|
|
return error('Invalid trailing CRLF: got "${trailing}"')
|
|
}
|
|
|
|
return data, true
|
|
}
|
|
|
|
// Parse hex string to int
|
|
fn parse_hex(hex_str string) !int {
|
|
mut result := 0
|
|
for c in hex_str {
|
|
result *= 16
|
|
if c >= `0` && c <= `9` {
|
|
result += int(c - `0`)
|
|
} else if c >= `a` && c <= `f` {
|
|
result += int(c - `a` + 10)
|
|
} else if c >= `A` && c <= `F` {
|
|
result += int(c - `A` + 10)
|
|
} else {
|
|
return error('Invalid hex character: ${c.ascii_str()}')
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// ============================================================================
|
|
// Property 10: Chunked 编码格式正确性
|
|
// Feature: sse-streaming-helper, Property 10: Chunked 编码格式正确性
|
|
// Validates: Requirements 6.2
|
|
//
|
|
// *For any* data written to the stream, the output SHALL follow HTTP chunked
|
|
// transfer encoding format:
|
|
// - Each chunk starts with the hex size followed by \r\n
|
|
// - Chunk data followed by \r\n
|
|
// - Final chunk is 0\r\n\r\n
|
|
// ============================================================================
|
|
|
|
// Test 10a: format_chunk produces valid chunked encoding for any byte array
|
|
fn test_property_10a_format_chunk_bytes() bool {
|
|
rand.seed([u32(time.now().unix()), u32(12345)])
|
|
|
|
for i in 0 .. test_iterations {
|
|
// Generate random data
|
|
data := generate_random_bytes(1000)
|
|
|
|
// Format as chunk
|
|
chunk := format_chunk(data)
|
|
|
|
// Verify chunk format
|
|
// 1. Should start with hex size
|
|
mut crlf_pos := -1
|
|
for j := 0; j < chunk.len - 1; j++ {
|
|
if chunk[j] == `\r` && chunk[j + 1] == `\n` {
|
|
crlf_pos = j
|
|
break
|
|
}
|
|
}
|
|
|
|
if crlf_pos == -1 {
|
|
println(' Iteration ${i}: No CRLF found in chunk header')
|
|
return false
|
|
}
|
|
|
|
size_hex := chunk[..crlf_pos]
|
|
parsed_size := parse_hex(size_hex) or {
|
|
println(' Iteration ${i}: Invalid hex size: ${size_hex}')
|
|
return false
|
|
}
|
|
|
|
// 2. Size should match data length
|
|
if parsed_size != data.len {
|
|
println(' Iteration ${i}: Size mismatch - expected ${data.len}, got ${parsed_size}')
|
|
return false
|
|
}
|
|
|
|
// 3. Data should be extractable and match original
|
|
extracted_data, _ := parse_chunked_data(chunk) or {
|
|
println(' Iteration ${i}: Failed to parse chunk: ${err}')
|
|
return false
|
|
}
|
|
|
|
if extracted_data != data {
|
|
println(' Iteration ${i}: Data mismatch after extraction')
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// Test 10b: format_chunk_string produces valid chunked encoding for any string
|
|
fn test_property_10b_format_chunk_string() bool {
|
|
rand.seed([u32(time.now().unix()), u32(54321)])
|
|
|
|
for i in 0 .. test_iterations {
|
|
// Generate random string
|
|
data := generate_random_string(500)
|
|
|
|
// Format as chunk
|
|
chunk := format_chunk_string(data)
|
|
|
|
// Verify chunk format
|
|
mut crlf_pos := -1
|
|
for j := 0; j < chunk.len - 1; j++ {
|
|
if chunk[j] == `\r` && chunk[j + 1] == `\n` {
|
|
crlf_pos = j
|
|
break
|
|
}
|
|
}
|
|
|
|
if crlf_pos == -1 {
|
|
println(' Iteration ${i}: No CRLF found in chunk header')
|
|
return false
|
|
}
|
|
|
|
size_hex := chunk[..crlf_pos]
|
|
parsed_size := parse_hex(size_hex) or {
|
|
println(' Iteration ${i}: Invalid hex size: ${size_hex}')
|
|
return false
|
|
}
|
|
|
|
// Size should match string byte length
|
|
if parsed_size != data.len {
|
|
println(' Iteration ${i}: Size mismatch - expected ${data.len}, got ${parsed_size}')
|
|
return false
|
|
}
|
|
|
|
// Extract and verify data
|
|
extracted_data, _ := parse_chunked_data(chunk) or {
|
|
println(' Iteration ${i}: Failed to parse chunk: ${err}')
|
|
return false
|
|
}
|
|
|
|
if extracted_data.bytestr() != data {
|
|
println(' Iteration ${i}: String mismatch after extraction')
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// Test 10c: format_final_chunk produces correct terminator
|
|
fn test_property_10c_final_chunk_format() bool {
|
|
final_chunk := format_final_chunk()
|
|
|
|
// Final chunk must be exactly "0\r\n\r\n"
|
|
expected := '0\r\n\r\n'
|
|
|
|
if final_chunk != expected {
|
|
println(' Final chunk mismatch - expected "${expected}", got "${final_chunk}"')
|
|
return false
|
|
}
|
|
|
|
// Verify it starts with zero size
|
|
if !final_chunk.starts_with('0\r\n') {
|
|
println(' Final chunk should start with "0\\r\\n"')
|
|
return false
|
|
}
|
|
|
|
// Verify it ends with empty line (double CRLF after size)
|
|
if !final_chunk.ends_with('\r\n\r\n') {
|
|
println(' Final chunk should end with "\\r\\n\\r\\n"')
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// Test 10d: Empty data produces empty string (no chunk)
|
|
fn test_property_10d_empty_data_handling() bool {
|
|
// Empty byte array
|
|
empty_bytes := []u8{}
|
|
chunk_bytes := format_chunk(empty_bytes)
|
|
if chunk_bytes != '' {
|
|
println(' Empty bytes should produce empty string, got "${chunk_bytes}"')
|
|
return false
|
|
}
|
|
|
|
// Empty string
|
|
chunk_string := format_chunk_string('')
|
|
if chunk_string != '' {
|
|
println(' Empty string should produce empty string, got "${chunk_string}"')
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// Test 10e: Chunk size is correctly formatted as lowercase hex
|
|
fn test_property_10e_hex_format() bool {
|
|
rand.seed([u32(time.now().unix()), u32(98765)])
|
|
|
|
// Test various sizes including edge cases
|
|
test_sizes := [1, 15, 16, 255, 256, 4095, 4096, 65535]
|
|
|
|
for size in test_sizes {
|
|
// Create data of specific size
|
|
mut data := []u8{len: size}
|
|
for i in 0 .. size {
|
|
data[i] = u8(i % 256)
|
|
}
|
|
|
|
chunk := format_chunk(data)
|
|
|
|
// Extract hex size
|
|
mut crlf_pos := -1
|
|
for j := 0; j < chunk.len - 1; j++ {
|
|
if chunk[j] == `\r` && chunk[j + 1] == `\n` {
|
|
crlf_pos = j
|
|
break
|
|
}
|
|
}
|
|
|
|
if crlf_pos == -1 {
|
|
println(' Size ${size}: No CRLF found')
|
|
return false
|
|
}
|
|
|
|
size_hex := chunk[..crlf_pos]
|
|
|
|
// Verify it's valid lowercase hex
|
|
for c in size_hex {
|
|
is_digit := c >= `0` && c <= `9`
|
|
is_lower_hex := c >= `a` && c <= `f`
|
|
if !is_digit && !is_lower_hex {
|
|
println(' Size ${size}: Invalid hex character "${c.ascii_str()}" in "${size_hex}"')
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Verify parsed value matches
|
|
parsed := parse_hex(size_hex) or {
|
|
println(' Size ${size}: Failed to parse hex "${size_hex}"')
|
|
return false
|
|
}
|
|
|
|
if parsed != size {
|
|
println(' Size ${size}: Hex "${size_hex}" parsed as ${parsed}')
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// Test 10f: Round-trip property - chunk then parse returns original data
|
|
fn test_property_10f_roundtrip() bool {
|
|
rand.seed([u32(time.now().unix()), u32(11111)])
|
|
|
|
for i in 0 .. test_iterations {
|
|
// Generate random data
|
|
original := generate_random_bytes(500)
|
|
|
|
// Format as chunk
|
|
chunk := format_chunk(original)
|
|
|
|
// Parse back
|
|
recovered, _ := parse_chunked_data(chunk) or {
|
|
println(' Iteration ${i}: Failed to parse: ${err}')
|
|
return false
|
|
}
|
|
|
|
// Verify round-trip
|
|
if recovered != original {
|
|
println(' Iteration ${i}: Round-trip failed - data mismatch')
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
fn main() {
|
|
println('🚀 开始 PicoevStreamWriter 属性测试...')
|
|
println('每个属性测试运行 ${test_iterations} 次迭代\n')
|
|
|
|
mut stats := PropertyTestStats{}
|
|
|
|
// Run Property 10 tests
|
|
// Feature: sse-streaming-helper, Property 10: Chunked 编码格式正确性
|
|
// Validates: Requirements 6.2
|
|
stats.run_property_test('Property 10a: format_chunk bytes encoding', test_property_10a_format_chunk_bytes)
|
|
stats.run_property_test('Property 10b: format_chunk_string encoding', test_property_10b_format_chunk_string)
|
|
stats.run_property_test('Property 10c: Final chunk format', test_property_10c_final_chunk_format)
|
|
stats.run_property_test('Property 10d: Empty data handling', test_property_10d_empty_data_handling)
|
|
stats.run_property_test('Property 10e: Hex format correctness', test_property_10e_hex_format)
|
|
stats.run_property_test('Property 10f: Round-trip property', test_property_10f_roundtrip)
|
|
|
|
// Print summary
|
|
stats.print_summary()
|
|
}
|