Files
v-hono/tests/test_stream_text_properties.v
meiseayoung 9ae9170bcb feat: add SSE streaming helper support
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
2026-01-04 15:13:28 +08:00

649 lines
18 KiB
V

// test_stream_text_properties.v
// Property-Based Testing for stream_text() function
// Feature: sse-streaming-helper
// Property 3: streamText() 设置正确的 HTTP 头
// Property 4: writeln() 添加换行符
// Property 5: sleep() 暂停正确的时间
// Validates: Requirements 2.1, 2.2, 2.3, 2.5, 2.6
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=== stream_text() 函数属性测试总结 ===')
println('总测试数: ${stats.total_tests}')
println('通过: ${stats.passed_tests}')
println('失败: ${stats.failed_tests}')
if stats.failed_tests == 0 {
println('🎉 所有属性测试通过!')
} else {
println('⚠️ 有 ${stats.failed_tests} 个属性测试失败')
}
}
// ============================================================================
// Mock StreamWriter for Testing
// ============================================================================
// MockStreamWriter - A mock implementation of StreamWriter for testing
// Captures all written data for verification
struct MockStreamWriter {
mut:
written_data []u8
connected bool
closed bool
flush_count int
}
fn MockStreamWriter.new() &MockStreamWriter {
return &MockStreamWriter{
written_data: []u8{}
connected: true
closed: false
flush_count: 0
}
}
fn (mut w MockStreamWriter) write(data []u8) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
// Write chunked format: size in hex, CRLF, data, CRLF
chunk_header := '${data.len:x}\r\n'
w.written_data << chunk_header.bytes()
w.written_data << data
w.written_data << '\r\n'.bytes()
}
fn (mut w MockStreamWriter) write_string(data string) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
// Write chunked format: size in hex, CRLF, data, CRLF
chunk_header := '${data.len:x}\r\n'
w.written_data << chunk_header.bytes()
w.written_data << data.bytes()
w.written_data << '\r\n'.bytes()
}
fn (mut w MockStreamWriter) flush() ! {
w.flush_count++
}
fn (mut w MockStreamWriter) close() ! {
if w.connected {
// Write the final chunk (zero-length chunk) to signal end of stream
w.written_data << '0\r\n\r\n'.bytes()
w.connected = false
w.closed = true
}
}
fn (w MockStreamWriter) is_connected() bool {
return w.connected
}
fn (w MockStreamWriter) get_written_string() string {
return w.written_data.bytestr()
}
// ============================================================================
// StreamContext for Testing (simplified version)
// ============================================================================
@[heap]
struct StreamContext {
mut:
writer &MockStreamWriter
is_closed bool
}
fn StreamContext.new(writer &MockStreamWriter) StreamContext {
return StreamContext{
writer: unsafe { writer }
is_closed: false
}
}
fn (mut ctx StreamContext) write_string(data string) ! {
if ctx.is_closed {
return error('Stream is closed')
}
if !ctx.writer.is_connected() {
ctx.is_closed = true
return error('Connection lost')
}
ctx.writer.write_string(data)!
}
fn (mut ctx StreamContext) writeln(data string) ! {
ctx.write_string(data)!
ctx.write_string('\n')!
}
fn (ctx StreamContext) sleep(ms int) {
time.sleep(ms * time.millisecond)
}
fn (mut ctx StreamContext) close() {
if !ctx.is_closed {
ctx.is_closed = true
ctx.writer.close() or {}
}
}
// ============================================================================
// Helper Functions
// ============================================================================
// get_stream_text_headers - Get the headers required for text streaming
fn get_stream_text_headers() map[string]string {
return {
'Content-Type': 'text/plain; charset=utf-8'
'Transfer-Encoding': 'chunked'
'X-Content-Type-Options': 'nosniff'
'Connection': 'keep-alive'
}
}
// Generate random string for testing (printable ASCII only)
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
}
// 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
}
// Parse a chunked encoded string and extract all chunks
fn parse_all_chunks(chunked string) !([]string, bool) {
mut chunks := []string{}
mut pos := 0
for pos < chunked.len {
// Find the size part (before \r\n)
mut crlf_pos := -1
for i := pos; 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 at position ${pos}')
}
size_hex := chunked[pos..crlf_pos]
size := parse_hex(size_hex) or {
return error('Invalid hex size: ${size_hex}')
}
// Check for final chunk
if size == 0 {
// Verify final chunk format
remaining := chunked[pos..]
if remaining != '0\r\n\r\n' {
return error('Invalid final chunk format')
}
break
}
// Extract data after the \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]
chunks << data
// 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')
}
pos = data_end + 2
}
return chunks, true
}
// ============================================================================
// Property 3: streamText() 设置正确的 HTTP 头
// Feature: sse-streaming-helper, Property 3
// Validates: Requirements 2.1, 2.2, 2.3
//
// *For any* call to streamText(c, callback), the response SHALL contain:
// - Content-Type: text/plain; charset=utf-8
// - Transfer-Encoding: chunked
// - X-Content-Type-Options: nosniff
// ============================================================================
// Test 3a: Content-Type header is set correctly
fn test_property_3a_content_type_header() bool {
headers := get_stream_text_headers()
// Verify Content-Type header exists
if 'Content-Type' !in headers {
println(' Content-Type header missing')
return false
}
// Verify value is 'text/plain; charset=utf-8'
if headers['Content-Type'] != 'text/plain; charset=utf-8' {
println(' Content-Type should be "text/plain; charset=utf-8", got "${headers['Content-Type']}"')
return false
}
return true
}
// Test 3b: Transfer-Encoding header is set correctly
fn test_property_3b_transfer_encoding_header() bool {
headers := get_stream_text_headers()
// Verify Transfer-Encoding header exists
if 'Transfer-Encoding' !in headers {
println(' Transfer-Encoding header missing')
return false
}
// Verify value is 'chunked'
if headers['Transfer-Encoding'] != 'chunked' {
println(' Transfer-Encoding should be "chunked", got "${headers['Transfer-Encoding']}"')
return false
}
return true
}
// Test 3c: X-Content-Type-Options header is set correctly
fn test_property_3c_x_content_type_options_header() bool {
headers := get_stream_text_headers()
// Verify X-Content-Type-Options header exists
if 'X-Content-Type-Options' !in headers {
println(' X-Content-Type-Options header missing')
return false
}
// Verify value is 'nosniff'
if headers['X-Content-Type-Options'] != 'nosniff' {
println(' X-Content-Type-Options should be "nosniff", got "${headers['X-Content-Type-Options']}"')
return false
}
return true
}
// Test 3d: Headers are consistent across multiple calls
fn test_property_3d_headers_consistency() bool {
for _ in 0 .. test_iterations {
headers1 := get_stream_text_headers()
headers2 := get_stream_text_headers()
// Headers should be identical
if headers1['Content-Type'] != headers2['Content-Type'] {
println(' Content-Type inconsistent between calls')
return false
}
if headers1['Transfer-Encoding'] != headers2['Transfer-Encoding'] {
println(' Transfer-Encoding inconsistent between calls')
return false
}
if headers1['X-Content-Type-Options'] != headers2['X-Content-Type-Options'] {
println(' X-Content-Type-Options inconsistent between calls')
return false
}
}
return true
}
// ============================================================================
// Property 4: writeln() 添加换行符
// Feature: sse-streaming-helper, Property 4
// Validates: Requirements 2.5
//
// *For any* string text, when stream.writeln(text) is called, the output
// SHALL be text followed by a newline character \n.
// ============================================================================
// Test 4a: writeln() appends newline for any string
fn test_property_4a_writeln_appends_newline() bool {
rand.seed([u32(time.now().unix()), u32(12345)])
for i in 0 .. test_iterations {
// Generate random text
text := generate_random_string(200)
// Create mock writer and context
mut writer := MockStreamWriter.new()
mut ctx := StreamContext.new(writer)
// Call writeln
ctx.writeln(text) or {
println(' Iteration ${i}: writeln failed: ${err}')
return false
}
// Close to finalize
ctx.close()
// Parse the written chunked data
written := writer.get_written_string()
chunks, _ := parse_all_chunks(written) or {
println(' Iteration ${i}: Failed to parse chunks: ${err}')
return false
}
// Should have exactly 2 chunks (text and newline)
if chunks.len != 2 {
println(' Iteration ${i}: Expected 2 chunks, got ${chunks.len}')
return false
}
// First chunk should be the text
if chunks[0] != text {
println(' Iteration ${i}: First chunk should be text')
return false
}
// Second chunk should be newline
if chunks[1] != '\n' {
println(' Iteration ${i}: Second chunk should be newline, got "${chunks[1]}"')
return false
}
}
return true
}
// Test 4b: writeln() with empty string still adds newline
fn test_property_4b_writeln_empty_string() bool {
// Create mock writer and context
mut writer := MockStreamWriter.new()
mut ctx := StreamContext.new(writer)
// Call writeln with empty string - this should fail because write_string skips empty
// Actually, writeln calls write_string twice: once for data, once for \n
// Empty string write is skipped, but \n should still be written
ctx.writeln('') or {
println(' writeln empty string failed: ${err}')
return false
}
// Close to finalize
ctx.close()
// Parse the written chunked data
written := writer.get_written_string()
chunks, _ := parse_all_chunks(written) or {
println(' Failed to parse chunks: ${err}')
return false
}
// Should have exactly 1 chunk (just the newline, empty string is skipped)
if chunks.len != 1 {
println(' Expected 1 chunk (newline only), got ${chunks.len}')
return false
}
// The chunk should be newline
if chunks[0] != '\n' {
println(' Chunk should be newline, got "${chunks[0]}"')
return false
}
return true
}
// Test 4c: Multiple writeln calls produce correct output
fn test_property_4c_multiple_writeln() bool {
rand.seed([u32(time.now().unix()), u32(54321)])
for i in 0 .. test_iterations {
// Generate multiple random lines
num_lines := rand.int_in_range(2, 5) or { 3 }
mut lines := []string{}
mut writer := MockStreamWriter.new()
mut ctx := StreamContext.new(writer)
for _ in 0 .. num_lines {
line := generate_random_string(50)
lines << line
ctx.writeln(line) or {
println(' Iteration ${i}: writeln failed: ${err}')
return false
}
}
// Close to finalize
ctx.close()
// Parse the written chunked data
written := writer.get_written_string()
chunks, _ := parse_all_chunks(written) or {
println(' Iteration ${i}: Failed to parse chunks: ${err}')
return false
}
// Should have 2 chunks per line (text + newline)
expected_chunks := num_lines * 2
if chunks.len != expected_chunks {
println(' Iteration ${i}: Expected ${expected_chunks} chunks, got ${chunks.len}')
return false
}
// Verify each line and newline pair
for j, line in lines {
text_idx := j * 2
newline_idx := j * 2 + 1
if chunks[text_idx] != line {
println(' Iteration ${i}: Line ${j} text mismatch')
return false
}
if chunks[newline_idx] != '\n' {
println(' Iteration ${i}: Line ${j} newline missing')
return false
}
}
}
return true
}
// ============================================================================
// Property 5: sleep() 暂停正确的时间
// Feature: sse-streaming-helper, Property 5
// Validates: Requirements 2.6
//
// *For any* positive integer ms, when stream.sleep(ms) is called, the execution
// SHALL pause for approximately ms milliseconds (within ±10% tolerance).
// ============================================================================
// Test 5a: sleep() pauses for approximately the specified time
fn test_property_5a_sleep_duration() bool {
// Test with a few specific durations to avoid long test times
test_durations := [10, 20, 50]
for duration in test_durations {
mut writer := MockStreamWriter.new()
ctx := StreamContext.new(writer)
// Measure time before and after sleep
start := time.now()
ctx.sleep(duration)
elapsed := time.now() - start
elapsed_ms := elapsed.milliseconds()
// Allow generous tolerance for system overhead (±50% plus overhead)
// Sleep timing can vary significantly depending on system load
min_expected := int(f64(duration) * 0.5)
max_expected := int(f64(duration) * 2.0) + 20
if elapsed_ms < min_expected || elapsed_ms > max_expected {
println(' sleep(${duration}) took ${elapsed_ms}ms, expected ${min_expected}-${max_expected}ms')
return false
}
}
return true
}
// Test 5b: sleep(0) returns immediately
fn test_property_5b_sleep_zero() bool {
mut writer := MockStreamWriter.new()
ctx := StreamContext.new(writer)
// Measure time for sleep(0)
start := time.now()
ctx.sleep(0)
elapsed := time.now() - start
elapsed_ms := elapsed.milliseconds()
// sleep(0) should return very quickly (within 10ms)
if elapsed_ms > 10 {
println(' sleep(0) took ${elapsed_ms}ms, expected < 10ms')
return false
}
return true
}
// Test 5c: Multiple sleeps accumulate correctly
fn test_property_5c_multiple_sleeps() bool {
mut writer := MockStreamWriter.new()
ctx := StreamContext.new(writer)
// Sleep multiple times
sleep_duration := 10
num_sleeps := 3
start := time.now()
for _ in 0 .. num_sleeps {
ctx.sleep(sleep_duration)
}
elapsed := time.now() - start
elapsed_ms := elapsed.milliseconds()
expected_total := sleep_duration * num_sleeps
// Allow generous tolerance for system overhead (±50% plus overhead)
// Sleep timing can vary significantly depending on system load
min_expected := int(f64(expected_total) * 0.5)
max_expected := int(f64(expected_total) * 2.0) + 30
if elapsed_ms < min_expected || elapsed_ms > max_expected {
println(' ${num_sleeps} sleeps of ${sleep_duration}ms took ${elapsed_ms}ms, expected ${min_expected}-${max_expected}ms')
return false
}
return true
}
fn main() {
println('🚀 开始 stream_text() 函数属性测试...')
println('每个属性测试运行 ${test_iterations} 次迭代\n')
mut stats := PropertyTestStats{}
// Run Property 3 tests
// Feature: sse-streaming-helper, Property 3: streamText() 设置正确的 HTTP 头
// Validates: Requirements 2.1, 2.2, 2.3
println('--- Property 3: streamText() 设置正确的 HTTP 头 ---')
stats.run_property_test('Property 3a: Content-Type header', test_property_3a_content_type_header)
stats.run_property_test('Property 3b: Transfer-Encoding header', test_property_3b_transfer_encoding_header)
stats.run_property_test('Property 3c: X-Content-Type-Options header', test_property_3c_x_content_type_options_header)
stats.run_property_test('Property 3d: Headers consistency', test_property_3d_headers_consistency)
println('')
// Run Property 4 tests
// Feature: sse-streaming-helper, Property 4: writeln() 添加换行符
// Validates: Requirements 2.5
println('--- Property 4: writeln() 添加换行符 ---')
stats.run_property_test('Property 4a: writeln() appends newline', test_property_4a_writeln_appends_newline)
stats.run_property_test('Property 4b: writeln() empty string', test_property_4b_writeln_empty_string)
stats.run_property_test('Property 4c: Multiple writeln calls', test_property_4c_multiple_writeln)
println('')
// Run Property 5 tests
// Feature: sse-streaming-helper, Property 5: sleep() 暂停正确的时间
// Validates: Requirements 2.6
println('--- Property 5: sleep() 暂停正确的时间 ---')
stats.run_property_test('Property 5a: sleep() duration', test_property_5a_sleep_duration)
stats.run_property_test('Property 5b: sleep(0) returns immediately', test_property_5b_sleep_zero)
stats.run_property_test('Property 5c: Multiple sleeps accumulate', test_property_5c_multiple_sleeps)
// Print summary
stats.print_summary()
}