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
This commit is contained in:
meiseayoung
2026-01-04 15:13:28 +08:00
parent 6ab6d3b99e
commit 9ae9170bcb
13 changed files with 5993 additions and 4 deletions
@@ -0,0 +1,434 @@
// 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()
}
@@ -0,0 +1,761 @@
// test_stream_error_handling_properties.v
// Property-Based Testing for Error Handling in SSE Streaming
// Feature: sse-streaming-helper
// Property 8: 错误处理器调用
// Property 9: 流自动关闭
// Validates: Requirements 4.1, 1.5, 5.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=== 错误处理属性测试总结 ===')
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)
// ============================================================================
// StreamContext - Context provided to streaming callbacks
@[heap]
struct StreamContext {
mut:
writer &MockStreamWriter
is_closed bool
abort_fn ?fn ()
}
// StreamContext.new - Create a new StreamContext with the given writer
fn StreamContext.new(writer &MockStreamWriter) StreamContext {
return StreamContext{
writer: unsafe { writer }
is_closed: false
abort_fn: none
}
}
// write - Write raw bytes to the stream
fn (mut ctx StreamContext) write(data []u8) ! {
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(data)!
}
// write_string - Write a string to the stream
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)!
}
// close - Close the stream
fn (mut ctx StreamContext) close() {
if !ctx.is_closed {
ctx.is_closed = true
ctx.writer.close() or {}
}
}
// is_open - Check if the stream is still open
fn (ctx StreamContext) is_open() bool {
return !ctx.is_closed && ctx.writer.is_connected()
}
// ============================================================================
// Callback and Error Handler Types
// ============================================================================
// StreamCallback - Callback function type for streaming
type StreamCallback = fn (mut StreamContext) !
// StreamErrorHandler - Error handler function type for streaming
type StreamErrorHandler = fn (err IError, mut ctx StreamContext)
// ============================================================================
// Stream Response Type
// ============================================================================
struct StreamResponse {
pub:
status_code int = 200
headers map[string]string
is_stream bool = true
}
// ============================================================================
// Streaming Helper Functions (for testing)
// ============================================================================
// stream - Basic binary streaming function with error handling
fn stream(mut writer MockStreamWriter, callback StreamCallback, error_handler ...StreamErrorHandler) StreamResponse {
// Create StreamContext with the writer
mut ctx := StreamContext.new(writer)
// Execute the callback and handle errors
callback(mut ctx) or {
// Handle error
if error_handler.len > 0 {
// Call custom error handler
error_handler[0](err, mut ctx)
} else {
// Default: log error to console
eprintln('[SSE Stream Error] ${err.msg()}')
}
}
// Auto-close the stream when callback completes
ctx.close()
// Return response headers for streaming
return StreamResponse{
status_code: 200
headers: {
'Transfer-Encoding': 'chunked'
'Connection': 'keep-alive'
}
is_stream: true
}
}
// ============================================================================
// Error Tracking for Tests
// ============================================================================
// Global variables to track error handler calls
__global error_handler_called = false
__global error_handler_error_msg = ''
__global error_handler_stream_open = false
__global error_handler_write_success = false
fn reset_error_tracking() {
error_handler_called = false
error_handler_error_msg = ''
error_handler_stream_open = false
error_handler_write_success = false
}
// ============================================================================
// Helper Functions
// ============================================================================
// Generate random error message
fn generate_random_error_message(max_len int) string {
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 _-.'
len := rand.int_in_range(5, max_len) or { 20 }
mut result := ''
for _ in 0 .. len {
idx := rand.int_in_range(0, chars.len) or { 0 }
result += chars[idx].ascii_str()
}
return result
}
// Generate random string for testing
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
}
// ============================================================================
// Property 8: 错误处理器调用
// Feature: sse-streaming-helper, Property 8
// Validates: Requirements 4.1
//
// *For any* callback that throws an error, if an error handler is provided,
// the error handler SHALL be called with the error and stream context.
// ============================================================================
// Test 8a: Custom error handler is called when callback throws error
fn test_property_8a_error_handler_called() bool {
rand.seed([u32(time.now().unix()), u32(88881)])
for i in 0 .. test_iterations {
reset_error_tracking()
// Generate random error message
error_msg := generate_random_error_message(50)
// Create mock writer
mut writer := MockStreamWriter.new()
// Define callback that throws an error
callback := fn [error_msg] (mut ctx StreamContext) ! {
return error(error_msg)
}
// Define error handler that tracks the call
error_handler := fn (err IError, mut ctx StreamContext) {
error_handler_called = true
error_handler_error_msg = err.msg()
}
// Call stream with error handler
_ := stream(mut writer, callback, error_handler)
// Verify error handler was called
if !error_handler_called {
println(' Iteration ${i}: Error handler was not called')
return false
}
// Verify error message was passed correctly
if error_handler_error_msg != error_msg {
println(' Iteration ${i}: Error message mismatch')
println(' Expected: ${error_msg}')
println(' Got: ${error_handler_error_msg}')
return false
}
}
return true
}
// Test 8b: Error handler receives valid stream context
fn test_property_8b_error_handler_receives_context() bool {
rand.seed([u32(time.now().unix()), u32(88882)])
for i in 0 .. test_iterations {
reset_error_tracking()
// Create mock writer
mut writer := MockStreamWriter.new()
// Define callback that throws an error
callback := fn (mut ctx StreamContext) ! {
return error('Test error')
}
// Define error handler that checks stream context
error_handler := fn (err IError, mut ctx StreamContext) {
error_handler_called = true
error_handler_stream_open = ctx.is_open()
}
// Call stream with error handler
_ := stream(mut writer, callback, error_handler)
// Verify error handler was called
if !error_handler_called {
println(' Iteration ${i}: Error handler was not called')
return false
}
// Verify stream was still open when error handler was called
if !error_handler_stream_open {
println(' Iteration ${i}: Stream should be open when error handler is called')
return false
}
}
return true
}
// Test 8c: Error handler can write to stream before it closes
fn test_property_8c_error_handler_can_write() bool {
rand.seed([u32(time.now().unix()), u32(88883)])
for i in 0 .. test_iterations {
reset_error_tracking()
// Generate random error response message
error_response := generate_random_string(30)
// Create mock writer
mut writer := MockStreamWriter.new()
// Define callback that throws an error
callback := fn (mut ctx StreamContext) ! {
return error('Test error')
}
// Define error handler that writes to stream
error_handler := fn [error_response] (err IError, mut ctx StreamContext) {
error_handler_called = true
ctx.write_string(error_response) or {
error_handler_write_success = false
return
}
error_handler_write_success = true
}
// Call stream with error handler
_ := stream(mut writer, callback, error_handler)
// Verify error handler was called
if !error_handler_called {
println(' Iteration ${i}: Error handler was not called')
return false
}
// Verify write was successful
if !error_handler_write_success {
println(' Iteration ${i}: Error handler failed to write to stream')
return false
}
// Verify the error response was written
written := writer.get_written_string()
if !written.contains(error_response) {
println(' Iteration ${i}: Error response not found in written data')
return false
}
}
return true
}
// Test 8d: Without error handler, error is logged (no crash)
fn test_property_8d_default_error_handling() bool {
rand.seed([u32(time.now().unix()), u32(88884)])
for i in 0 .. test_iterations {
// Generate random error message
error_msg := generate_random_error_message(50)
// Create mock writer
mut writer := MockStreamWriter.new()
// Define callback that throws an error
callback := fn [error_msg] (mut ctx StreamContext) ! {
return error(error_msg)
}
// Call stream WITHOUT error handler - should not crash
// Default behavior logs to console via eprintln
response := stream(mut writer, callback)
// Verify stream completed (no crash)
if response.status_code != 200 {
println(' Iteration ${i}: Unexpected status code ${response.status_code}')
return false
}
// Verify stream was closed
if !writer.closed {
println(' Iteration ${i}: Stream should be closed after error')
return false
}
}
return true
}
// Test 8e: Error handler called with correct error type
fn test_property_8e_error_type_preserved() bool {
rand.seed([u32(time.now().unix()), u32(88885)])
for i in 0 .. test_iterations {
reset_error_tracking()
// Generate random error message
error_msg := generate_random_error_message(50)
// Create mock writer
mut writer := MockStreamWriter.new()
// Define callback that throws an error
callback := fn [error_msg] (mut ctx StreamContext) ! {
return error(error_msg)
}
// Define error handler that checks error type
error_handler := fn [error_msg] (err IError, mut ctx StreamContext) {
error_handler_called = true
// Verify error message matches
if err.msg() == error_msg {
error_handler_write_success = true
}
}
// Call stream with error handler
_ := stream(mut writer, callback, error_handler)
// Verify error handler was called with correct error
if !error_handler_called {
println(' Iteration ${i}: Error handler was not called')
return false
}
if !error_handler_write_success {
println(' Iteration ${i}: Error message was not preserved correctly')
return false
}
}
return true
}
// ============================================================================
// Property 9: 流自动关闭
// Feature: sse-streaming-helper, Property 9
// Validates: Requirements 1.5, 5.2
//
// *For any* stream where the callback completes normally, the stream SHALL be
// automatically closed after callback execution.
// ============================================================================
// Test 9a: Stream is closed after successful callback completion
fn test_property_9a_stream_closed_after_success() bool {
rand.seed([u32(time.now().unix()), u32(99991)])
for i in 0 .. test_iterations {
// Create mock writer
mut writer := MockStreamWriter.new()
// Generate random data to write
data := generate_random_string(50)
// Define callback that completes successfully
callback := fn [data] (mut ctx StreamContext) ! {
ctx.write_string(data)!
}
// Call stream
_ := stream(mut writer, callback)
// Verify stream was closed
if !writer.closed {
println(' Iteration ${i}: Stream should be closed after callback completes')
return false
}
// Verify writer is disconnected
if writer.connected {
println(' Iteration ${i}: Writer should be disconnected after close')
return false
}
}
return true
}
// Test 9b: Stream is closed after callback throws error
fn test_property_9b_stream_closed_after_error() bool {
rand.seed([u32(time.now().unix()), u32(99992)])
for i in 0 .. test_iterations {
// Create mock writer
mut writer := MockStreamWriter.new()
// Generate random error message
error_msg := generate_random_error_message(30)
// Define callback that throws an error
callback := fn [error_msg] (mut ctx StreamContext) ! {
return error(error_msg)
}
// Call stream (with or without error handler)
_ := stream(mut writer, callback)
// Verify stream was closed even after error
if !writer.closed {
println(' Iteration ${i}: Stream should be closed after error')
return false
}
// Verify writer is disconnected
if writer.connected {
println(' Iteration ${i}: Writer should be disconnected after close')
return false
}
}
return true
}
// Test 9c: Final chunk marker is written when stream closes
fn test_property_9c_final_chunk_written() bool {
rand.seed([u32(time.now().unix()), u32(99993)])
for i in 0 .. test_iterations {
// Create mock writer
mut writer := MockStreamWriter.new()
// Generate random data to write
data := generate_random_string(50)
// Define callback that completes successfully
callback := fn [data] (mut ctx StreamContext) ! {
ctx.write_string(data)!
}
// Call stream
_ := stream(mut writer, callback)
// Verify final chunk marker is present
written := writer.get_written_string()
if !written.ends_with('0\r\n\r\n') {
println(' Iteration ${i}: Missing final chunk marker')
return false
}
}
return true
}
// Test 9d: Stream closed only once (idempotent close)
fn test_property_9d_idempotent_close() bool {
rand.seed([u32(time.now().unix()), u32(99994)])
for i in 0 .. test_iterations {
// Create mock writer
mut writer := MockStreamWriter.new()
// Define callback that manually closes the stream
callback := fn (mut ctx StreamContext) ! {
ctx.write_string('test')!
ctx.close() // Manual close
}
// Call stream (which will also try to close)
_ := stream(mut writer, callback)
// Verify stream was closed
if !writer.closed {
println(' Iteration ${i}: Stream should be closed')
return false
}
// Count final chunk markers - should be exactly one
written := writer.get_written_string()
mut final_chunk_count := 0
mut pos := 0
for {
idx := written[pos..].index('0\r\n\r\n') or { break }
final_chunk_count++
pos += idx + 5
}
if final_chunk_count != 1 {
println(' Iteration ${i}: Expected 1 final chunk marker, got ${final_chunk_count}')
return false
}
}
return true
}
// Test 9e: Stream closed after error handler writes
fn test_property_9e_closed_after_error_handler_writes() bool {
rand.seed([u32(time.now().unix()), u32(99995)])
for i in 0 .. test_iterations {
// Create mock writer
mut writer := MockStreamWriter.new()
// Generate random error response
error_response := generate_random_string(30)
// Define callback that throws an error
callback := fn (mut ctx StreamContext) ! {
return error('Test error')
}
// Define error handler that writes to stream
error_handler := fn [error_response] (err IError, mut ctx StreamContext) {
ctx.write_string(error_response) or {}
}
// Call stream with error handler
_ := stream(mut writer, callback, error_handler)
// Verify stream was closed after error handler
if !writer.closed {
println(' Iteration ${i}: Stream should be closed after error handler')
return false
}
// Verify error response was written before close
written := writer.get_written_string()
if !written.contains(error_response) {
println(' Iteration ${i}: Error response should be written before close')
return false
}
// Verify final chunk marker is present
if !written.ends_with('0\r\n\r\n') {
println(' Iteration ${i}: Missing final chunk marker')
return false
}
}
return true
}
// Test 9f: Empty callback still closes stream
fn test_property_9f_empty_callback_closes() bool {
for i in 0 .. test_iterations {
// Create mock writer
mut writer := MockStreamWriter.new()
// Define empty callback
callback := fn (mut ctx StreamContext) ! {
// Do nothing
}
// Call stream
_ := stream(mut writer, callback)
// Verify stream was closed
if !writer.closed {
println(' Iteration ${i}: Stream should be closed even with empty callback')
return false
}
}
return true
}
fn main() {
println('🚀 开始错误处理属性测试...')
println('每个属性测试运行 ${test_iterations} 次迭代\n')
mut stats := PropertyTestStats{}
// Run Property 8 tests
// Feature: sse-streaming-helper, Property 8: 错误处理器调用
// Validates: Requirements 4.1
println('--- Property 8: 错误处理器调用 ---')
stats.run_property_test('Property 8a: Error handler called on error', test_property_8a_error_handler_called)
stats.run_property_test('Property 8b: Error handler receives context', test_property_8b_error_handler_receives_context)
stats.run_property_test('Property 8c: Error handler can write', test_property_8c_error_handler_can_write)
stats.run_property_test('Property 8d: Default error handling', test_property_8d_default_error_handling)
stats.run_property_test('Property 8e: Error type preserved', test_property_8e_error_type_preserved)
println('')
// Run Property 9 tests
// Feature: sse-streaming-helper, Property 9: 流自动关闭
// Validates: Requirements 1.5, 5.2
println('--- Property 9: 流自动关闭 ---')
stats.run_property_test('Property 9a: Stream closed after success', test_property_9a_stream_closed_after_success)
stats.run_property_test('Property 9b: Stream closed after error', test_property_9b_stream_closed_after_error)
stats.run_property_test('Property 9c: Final chunk written', test_property_9c_final_chunk_written)
stats.run_property_test('Property 9d: Idempotent close', test_property_9d_idempotent_close)
stats.run_property_test('Property 9e: Closed after error handler writes', test_property_9e_closed_after_error_handler_writes)
stats.run_property_test('Property 9f: Empty callback closes stream', test_property_9f_empty_callback_closes)
// Print summary
stats.print_summary()
}
+586
View File
@@ -0,0 +1,586 @@
// test_stream_function_properties.v
// Property-Based Testing for stream() function
// Feature: sse-streaming-helper
// Property 1: stream() 设置正确的 Transfer-Encoding 头
// Property 2: write() 正确输出数据
// Validates: Requirements 1.1, 1.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=== stream() 函数属性测试总结 ===')
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()
}
// ============================================================================
// Helper Functions
// ============================================================================
// get_stream_headers - Get the headers required for basic streaming
fn get_stream_headers() map[string]string {
return {
'Transfer-Encoding': 'chunked'
'Connection': 'keep-alive'
}
}
// 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)
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) !([][]u8, bool) {
mut chunks := [][]u8{}
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].bytes()
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 1: stream() 设置正确的 Transfer-Encoding 头
// Feature: sse-streaming-helper, Property 1
// Validates: Requirements 1.1
//
// *For any* call to stream(c, callback), the response SHALL contain
// Transfer-Encoding: chunked header.
// ============================================================================
// Test 1a: get_stream_headers returns Transfer-Encoding: chunked
fn test_property_1a_transfer_encoding_header() bool {
headers := get_stream_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 1b: get_stream_headers returns Connection: keep-alive
fn test_property_1b_connection_header() bool {
headers := get_stream_headers()
// Verify Connection header exists
if 'Connection' !in headers {
println(' Connection header missing')
return false
}
// Verify value is 'keep-alive'
if headers['Connection'] != 'keep-alive' {
println(' Connection should be "keep-alive", got "${headers['Connection']}"')
return false
}
return true
}
// Test 1c: Headers are consistent across multiple calls
fn test_property_1c_headers_consistency() bool {
for _ in 0 .. test_iterations {
headers1 := get_stream_headers()
headers2 := get_stream_headers()
// Headers should be identical
if headers1['Transfer-Encoding'] != headers2['Transfer-Encoding'] {
println(' Transfer-Encoding inconsistent between calls')
return false
}
if headers1['Connection'] != headers2['Connection'] {
println(' Connection inconsistent between calls')
return false
}
}
return true
}
// ============================================================================
// Property 2: write() 正确输出数据
// Feature: sse-streaming-helper, Property 2
// Validates: Requirements 1.2
//
// *For any* byte array data, when stream.write(data) is called, the output
// stream SHALL contain the exact bytes from data in chunked transfer encoding format.
// ============================================================================
// Test 2a: write() outputs data in chunked format for any byte array
fn test_property_2a_write_bytes_chunked() bool {
rand.seed([u32(time.now().unix()), u32(12345)])
for i in 0 .. test_iterations {
// Generate random data
data := generate_random_bytes(500)
// Create mock writer and write data
mut writer := MockStreamWriter.new()
writer.write(data) or {
println(' Iteration ${i}: write failed: ${err}')
return false
}
// Parse the written chunked data
written := writer.get_written_string()
chunks, _ := parse_all_chunks(written + '0\r\n\r\n') or {
println(' Iteration ${i}: Failed to parse chunks: ${err}')
return false
}
// Should have exactly one chunk
if chunks.len != 1 {
println(' Iteration ${i}: Expected 1 chunk, got ${chunks.len}')
return false
}
// Chunk data should match original
if chunks[0] != data {
println(' Iteration ${i}: Data mismatch')
return false
}
}
return true
}
// Test 2b: write_string() outputs data in chunked format for any string
fn test_property_2b_write_string_chunked() bool {
rand.seed([u32(time.now().unix()), u32(54321)])
for i in 0 .. test_iterations {
// Generate random string
data := generate_random_string(500)
// Create mock writer and write data
mut writer := MockStreamWriter.new()
writer.write_string(data) or {
println(' Iteration ${i}: write_string failed: ${err}')
return false
}
// Parse the written chunked data
written := writer.get_written_string()
chunks, _ := parse_all_chunks(written + '0\r\n\r\n') or {
println(' Iteration ${i}: Failed to parse chunks: ${err}')
return false
}
// Should have exactly one chunk
if chunks.len != 1 {
println(' Iteration ${i}: Expected 1 chunk, got ${chunks.len}')
return false
}
// Chunk data should match original string
if chunks[0].bytestr() != data {
println(' Iteration ${i}: String mismatch')
return false
}
}
return true
}
// Test 2c: Multiple writes produce multiple chunks
fn test_property_2c_multiple_writes() bool {
rand.seed([u32(time.now().unix()), u32(98765)])
for i in 0 .. test_iterations {
// Generate multiple random data pieces
num_writes := rand.int_in_range(2, 10) or { 3 }
mut original_data := [][]u8{}
mut writer := MockStreamWriter.new()
for _ in 0 .. num_writes {
data := generate_random_bytes(100)
original_data << data
writer.write(data) or {
println(' Iteration ${i}: write failed: ${err}')
return false
}
}
// Parse the written chunked data
written := writer.get_written_string()
chunks, _ := parse_all_chunks(written + '0\r\n\r\n') or {
println(' Iteration ${i}: Failed to parse chunks: ${err}')
return false
}
// Should have same number of chunks as writes
if chunks.len != num_writes {
println(' Iteration ${i}: Expected ${num_writes} chunks, got ${chunks.len}')
return false
}
// Each chunk should match original data
for j, chunk in chunks {
if chunk != original_data[j] {
println(' Iteration ${i}: Chunk ${j} data mismatch')
return false
}
}
}
return true
}
// Test 2d: close() writes final chunk marker
fn test_property_2d_close_final_chunk() bool {
for _ in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
// Write some data
data := generate_random_bytes(50)
writer.write(data) or {
println(' write failed')
return false
}
// Close the writer
writer.close() or {
println(' close failed')
return false
}
// Verify final chunk marker is present
written := writer.get_written_string()
if !written.ends_with('0\r\n\r\n') {
println(' Missing final chunk marker')
return false
}
// Verify writer is marked as closed
if writer.connected {
println(' Writer should be disconnected after close')
return false
}
if !writer.closed {
println(' Writer should be marked as closed')
return false
}
}
return true
}
// Test 2e: Empty data is handled correctly (no chunk written)
fn test_property_2e_empty_data() bool {
mut writer := MockStreamWriter.new()
// Write empty byte array
writer.write([]u8{}) or {
println(' write empty bytes failed')
return false
}
// Write empty string
writer.write_string('') or {
println(' write empty string failed')
return false
}
// No data should be written for empty inputs
if writer.written_data.len != 0 {
println(' Empty data should not produce output, got ${writer.written_data.len} bytes')
return false
}
return true
}
// Test 2f: Round-trip property - write then parse returns original data
fn test_property_2f_roundtrip() bool {
rand.seed([u32(time.now().unix()), u32(11111)])
for i in 0 .. test_iterations {
// Generate random data
original := generate_random_bytes(500)
// Write to mock writer
mut writer := MockStreamWriter.new()
writer.write(original) or {
println(' Iteration ${i}: write failed')
return false
}
writer.close() or {
println(' Iteration ${i}: close failed')
return false
}
// Parse back
written := writer.get_written_string()
chunks, _ := parse_all_chunks(written) or {
println(' Iteration ${i}: Failed to parse: ${err}')
return false
}
// Verify round-trip
if chunks.len != 1 {
println(' Iteration ${i}: Expected 1 chunk, got ${chunks.len}')
return false
}
if chunks[0] != original {
println(' Iteration ${i}: Round-trip failed - data mismatch')
return false
}
}
return true
}
// Test 2g: Writing to closed connection returns error
fn test_property_2g_write_after_close() bool {
mut writer := MockStreamWriter.new()
// Close the writer
writer.close() or {
println(' close failed')
return false
}
// Try to write - should fail
writer.write('test'.bytes()) or {
// Expected error
return true
}
println(' Writing to closed connection should fail')
return false
}
fn main() {
println('🚀 开始 stream() 函数属性测试...')
println('每个属性测试运行 ${test_iterations} 次迭代\n')
mut stats := PropertyTestStats{}
// Run Property 1 tests
// Feature: sse-streaming-helper, Property 1: stream() 设置正确的 Transfer-Encoding 头
// Validates: Requirements 1.1
println('--- Property 1: stream() 设置正确的 Transfer-Encoding 头 ---')
stats.run_property_test('Property 1a: Transfer-Encoding header', test_property_1a_transfer_encoding_header)
stats.run_property_test('Property 1b: Connection header', test_property_1b_connection_header)
stats.run_property_test('Property 1c: Headers consistency', test_property_1c_headers_consistency)
println('')
// Run Property 2 tests
// Feature: sse-streaming-helper, Property 2: write() 正确输出数据
// Validates: Requirements 1.2
println('--- Property 2: write() 正确输出数据 ---')
stats.run_property_test('Property 2a: write() bytes chunked format', test_property_2a_write_bytes_chunked)
stats.run_property_test('Property 2b: write_string() chunked format', test_property_2b_write_string_chunked)
stats.run_property_test('Property 2c: Multiple writes produce chunks', test_property_2c_multiple_writes)
stats.run_property_test('Property 2d: close() writes final chunk', test_property_2d_close_final_chunk)
stats.run_property_test('Property 2e: Empty data handling', test_property_2e_empty_data)
stats.run_property_test('Property 2f: Round-trip property', test_property_2f_roundtrip)
stats.run_property_test('Property 2g: Write after close fails', test_property_2g_write_after_close)
// Print summary
stats.print_summary()
}
+988
View File
@@ -0,0 +1,988 @@
// test_stream_sse_properties.v
// Property-Based Testing for stream_sse() function
// Feature: sse-streaming-helper
// Property 6: streamSSE() 设置正确的 HTTP 头
// Property 7: SSE 事件格式化正确性
// Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9
module main
import rand
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_sse() 函数属性测试总结 ===')
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()
}
// extract_sse_content - Extract the actual SSE content from chunked encoding
// Removes the chunk size headers and CRLF markers to get the raw SSE data
fn (w MockStreamWriter) extract_sse_content() string {
raw := w.written_data.bytestr()
mut result := []u8{}
mut i := 0
for i < raw.len {
// Find the chunk size (hex number before \r\n)
mut size_end := i
for size_end < raw.len && raw[size_end] != `\r` {
size_end++
}
if size_end >= raw.len {
break
}
// Parse chunk size from hex string
size_str := raw[i..size_end]
chunk_size := parse_hex_int(size_str)
if chunk_size == 0 {
// Final chunk
break
}
// Skip \r\n after size
i = size_end + 2
// Extract chunk data
if i + chunk_size <= raw.len {
result << raw[i..i + chunk_size].bytes()
}
// Skip chunk data and trailing \r\n
i = i + chunk_size + 2
}
return result.bytestr()
}
// parse_hex_int - Parse a hex string to integer
fn parse_hex_int(s string) int {
mut result := 0
for c in s {
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)
}
}
return result
}
// ============================================================================
// SSEEvent Structure for Testing
// ============================================================================
// SSEEvent - Server-Sent Events event structure
// Mirrors the implementation in streaming.v
struct SSEEvent {
data string // Event data (required) - supports multi-line
event string // Event type name (optional)
id string // Event ID for client reconnection (optional)
retry int // Reconnection time in milliseconds (optional, 0 = not set)
}
// ============================================================================
// StreamContext for Testing
// ============================================================================
// TestStreamContext - A test implementation of StreamContext
// Provides write_sse method for testing SSE event formatting
struct TestStreamContext {
mut:
writer &MockStreamWriter
is_closed bool
}
fn TestStreamContext.new(writer &MockStreamWriter) TestStreamContext {
return TestStreamContext{
writer: unsafe { writer }
is_closed: false
}
}
fn (mut ctx TestStreamContext) 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)!
}
// write_sse - Write an SSE event to the stream
// Formats the event according to the SSE specification:
// - event: {type}\n (if event field is set)
// - data: {line}\n (for each line of data)
// - id: {id}\n (if id field is set)
// - retry: {ms}\n (if retry > 0)
// - \n (event separator)
fn (mut ctx TestStreamContext) write_sse(event SSEEvent) ! {
if ctx.is_closed {
return error('Stream is closed')
}
if !ctx.writer.is_connected() {
ctx.is_closed = true
return error('Connection lost')
}
// Write event type if specified
if event.event.len > 0 {
ctx.writer.write_string('event: ${event.event}\n')!
}
// Write data field - handle multi-line data
lines := event.data.split('\n')
for line in lines {
ctx.writer.write_string('data: ${line}\n')!
}
// Write id if specified
if event.id.len > 0 {
ctx.writer.write_string('id: ${event.id}\n')!
}
// Write retry if specified (must be > 0)
if event.retry > 0 {
ctx.writer.write_string('retry: ${event.retry}\n')!
}
// Write event separator (empty line)
ctx.writer.write_string('\n')!
// Flush to ensure event is sent immediately
ctx.writer.flush()!
}
fn (mut ctx TestStreamContext) close() {
if !ctx.is_closed {
ctx.is_closed = true
ctx.writer.close() or {}
}
}
// ============================================================================
// Random Data Generators for Property Testing
// ============================================================================
// generate_random_string - Generate a random alphanumeric string
fn generate_random_string(min_len int, max_len int) string {
len := rand.int_in_range(min_len, max_len + 1) or { min_len }
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
mut result := []u8{cap: len}
for _ in 0 .. len {
idx := rand.int_in_range(0, chars.len) or { 0 }
result << chars[idx]
}
return result.bytestr()
}
// generate_random_multiline_string - Generate a random string with newlines
fn generate_random_multiline_string(min_lines int, max_lines int) string {
num_lines := rand.int_in_range(min_lines, max_lines + 1) or { min_lines }
mut lines := []string{cap: num_lines}
for _ in 0 .. num_lines {
lines << generate_random_string(1, 20)
}
return lines.join('\n')
}
// generate_random_sse_event - Generate a random SSE event
fn generate_random_sse_event() SSEEvent {
// Randomly decide which optional fields to include
include_event := rand.int_in_range(0, 2) or { 0 } == 1
include_id := rand.int_in_range(0, 2) or { 0 } == 1
include_retry := rand.int_in_range(0, 2) or { 0 } == 1
is_multiline := rand.int_in_range(0, 3) or { 0 } == 0 // 1/3 chance of multiline
mut data := ''
if is_multiline {
data = generate_random_multiline_string(2, 5)
} else {
data = generate_random_string(1, 50)
}
return SSEEvent{
data: data
event: if include_event { generate_random_string(3, 15) } else { '' }
id: if include_id { generate_random_string(1, 10) } else { '' }
retry: if include_retry { rand.int_in_range(100, 10000) or { 1000 } } else { 0 }
}
}
// ============================================================================
// Helper Functions
// ============================================================================
// get_stream_sse_headers - Get the headers required for SSE streaming
// This mirrors the implementation in streaming.v
fn get_stream_sse_headers() map[string]string {
return {
'Content-Type': 'text/event-stream'
'Cache-Control': 'no-cache'
'Connection': 'keep-alive'
'Transfer-Encoding': 'chunked'
}
}
// ============================================================================
// Property 6: streamSSE() 设置正确的 HTTP 头
// Feature: sse-streaming-helper, Property 6
// Validates: Requirements 3.1, 3.2, 3.3
//
// *For any* call to streamSSE(c, callback), the response SHALL contain:
// - Content-Type: text/event-stream
// - Cache-Control: no-cache
// - Connection: keep-alive
// ============================================================================
// Test 6a: Content-Type header is set correctly for SSE
fn test_property_6a_content_type_header() bool {
headers := get_stream_sse_headers()
// Verify Content-Type header exists
if 'Content-Type' !in headers {
println(' Content-Type header missing')
return false
}
// Verify value is 'text/event-stream' (Requirement 3.1)
if headers['Content-Type'] != 'text/event-stream' {
println(' Content-Type should be "text/event-stream", got "${headers['Content-Type']}"')
return false
}
return true
}
// Test 6b: Cache-Control header is set correctly for SSE
fn test_property_6b_cache_control_header() bool {
headers := get_stream_sse_headers()
// Verify Cache-Control header exists
if 'Cache-Control' !in headers {
println(' Cache-Control header missing')
return false
}
// Verify value is 'no-cache' (Requirement 3.2)
if headers['Cache-Control'] != 'no-cache' {
println(' Cache-Control should be "no-cache", got "${headers['Cache-Control']}"')
return false
}
return true
}
// Test 6c: Connection header is set correctly for SSE
fn test_property_6c_connection_header() bool {
headers := get_stream_sse_headers()
// Verify Connection header exists
if 'Connection' !in headers {
println(' Connection header missing')
return false
}
// Verify value is 'keep-alive' (Requirement 3.3)
if headers['Connection'] != 'keep-alive' {
println(' Connection should be "keep-alive", got "${headers['Connection']}"')
return false
}
return true
}
// Test 6d: Transfer-Encoding header is set for chunked transfer
fn test_property_6d_transfer_encoding_header() bool {
headers := get_stream_sse_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 6e: Headers are consistent across multiple calls
fn test_property_6e_headers_consistency() bool {
for _ in 0 .. test_iterations {
headers1 := get_stream_sse_headers()
headers2 := get_stream_sse_headers()
// Headers should be identical
if headers1['Content-Type'] != headers2['Content-Type'] {
println(' Content-Type inconsistent between calls')
return false
}
if headers1['Cache-Control'] != headers2['Cache-Control'] {
println(' Cache-Control inconsistent between calls')
return false
}
if headers1['Connection'] != headers2['Connection'] {
println(' Connection inconsistent between calls')
return false
}
if headers1['Transfer-Encoding'] != headers2['Transfer-Encoding'] {
println(' Transfer-Encoding inconsistent between calls')
return false
}
}
return true
}
// Test 6f: All required SSE headers are present
fn test_property_6f_all_required_headers_present() bool {
headers := get_stream_sse_headers()
required_headers := ['Content-Type', 'Cache-Control', 'Connection', 'Transfer-Encoding']
for header in required_headers {
if header !in headers {
println(' Required header "${header}" is missing')
return false
}
}
return true
}
// Test 6g: SSE headers differ from text streaming headers
fn test_property_6g_sse_headers_differ_from_text() bool {
sse_headers := get_stream_sse_headers()
// SSE should use text/event-stream, not text/plain
if sse_headers['Content-Type'] == 'text/plain; charset=utf-8' {
println(' SSE Content-Type should not be text/plain')
return false
}
// SSE should have Cache-Control: no-cache
if 'Cache-Control' !in sse_headers {
println(' SSE should have Cache-Control header')
return false
}
return true
}
// ============================================================================
// Property 7: SSE 事件格式化正确性
// Feature: sse-streaming-helper, Property 7
// Validates: Requirements 3.4, 3.5, 3.6, 3.7, 3.8, 3.9
//
// *For any* SSEEvent with fields (data, event, id, retry), when stream.writeSSE(event) is called:
// - If event is non-empty, output SHALL start with event: {event}\n
// - Output SHALL contain data: {data}\n for each line of data
// - If id is non-empty, output SHALL contain id: {id}\n
// - If retry > 0, output SHALL contain retry: {retry}\n
// - Output SHALL end with an additional \n to separate events
// ============================================================================
// Test 7a: Data field is always written correctly (Requirement 3.5)
// *For any* SSE event with data, the output SHALL contain "data: {value}\n"
fn test_property_7a_data_field_formatting() bool {
for i in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
mut ctx := TestStreamContext.new(writer)
// Generate random single-line data
data := generate_random_string(1, 50)
event := SSEEvent{
data: data
}
ctx.write_sse(event) or {
println(' Iteration ${i}: write_sse failed: ${err}')
return false
}
content := writer.extract_sse_content()
expected := 'data: ${data}\n'
if !content.contains(expected) {
println(' Iteration ${i}: Expected data line "${expected}" not found in output')
println(' Got: "${content}"')
return false
}
}
return true
}
// Test 7b: Multi-line data is split correctly (Requirement 3.5)
// *For any* SSE event with multi-line data, each line SHALL be prefixed with "data: "
fn test_property_7b_multiline_data_formatting() bool {
for i in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
mut ctx := TestStreamContext.new(writer)
// Generate random multi-line data
data := generate_random_multiline_string(2, 5)
event := SSEEvent{
data: data
}
ctx.write_sse(event) or {
println(' Iteration ${i}: write_sse failed: ${err}')
return false
}
content := writer.extract_sse_content()
lines := data.split('\n')
// Each line of data should be prefixed with "data: "
for line in lines {
expected := 'data: ${line}\n'
if !content.contains(expected) {
println(' Iteration ${i}: Expected data line "${expected}" not found')
println(' Original data: "${data}"')
println(' Got: "${content}"')
return false
}
}
}
return true
}
// Test 7c: Event field is written before data (Requirement 3.6)
// *For any* SSE event with event field, output SHALL start with "event: {value}\n"
fn test_property_7c_event_field_before_data() bool {
for i in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
mut ctx := TestStreamContext.new(writer)
event_type := generate_random_string(3, 15)
data := generate_random_string(1, 30)
event := SSEEvent{
data: data
event: event_type
}
ctx.write_sse(event) or {
println(' Iteration ${i}: write_sse failed: ${err}')
return false
}
content := writer.extract_sse_content()
event_line := 'event: ${event_type}\n'
data_line := 'data: ${data}\n'
// Event line should exist
if !content.contains(event_line) {
println(' Iteration ${i}: Event line "${event_line}" not found')
return false
}
// Event line should come before data line
event_pos := content.index(event_line) or { -1 }
data_pos := content.index(data_line) or { -1 }
if event_pos >= data_pos {
println(' Iteration ${i}: Event line should come before data line')
println(' Event pos: ${event_pos}, Data pos: ${data_pos}')
return false
}
}
return true
}
// Test 7d: Event field is omitted when empty (Requirement 3.6)
// *For any* SSE event with empty event field, output SHALL NOT contain "event: "
fn test_property_7d_empty_event_field_omitted() bool {
for i in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
mut ctx := TestStreamContext.new(writer)
data := generate_random_string(1, 30)
event := SSEEvent{
data: data
event: '' // Empty event field
}
ctx.write_sse(event) or {
println(' Iteration ${i}: write_sse failed: ${err}')
return false
}
content := writer.extract_sse_content()
// Should not contain "event: " prefix
if content.contains('event: ') {
println(' Iteration ${i}: Empty event field should not produce "event: " line')
println(' Got: "${content}"')
return false
}
}
return true
}
// Test 7e: ID field is written after data (Requirement 3.7)
// *For any* SSE event with id field, output SHALL contain "id: {value}\n" after data
fn test_property_7e_id_field_after_data() bool {
for i in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
mut ctx := TestStreamContext.new(writer)
id := generate_random_string(1, 10)
data := generate_random_string(1, 30)
event := SSEEvent{
data: data
id: id
}
ctx.write_sse(event) or {
println(' Iteration ${i}: write_sse failed: ${err}')
return false
}
content := writer.extract_sse_content()
id_line := 'id: ${id}\n'
data_line := 'data: ${data}\n'
// ID line should exist
if !content.contains(id_line) {
println(' Iteration ${i}: ID line "${id_line}" not found')
return false
}
// ID line should come after data line
id_pos := content.index(id_line) or { -1 }
data_pos := content.index(data_line) or { -1 }
if id_pos <= data_pos {
println(' Iteration ${i}: ID line should come after data line')
println(' ID pos: ${id_pos}, Data pos: ${data_pos}')
return false
}
}
return true
}
// Test 7f: ID field is omitted when empty (Requirement 3.7)
// *For any* SSE event with empty id field, output SHALL NOT contain "id: "
fn test_property_7f_empty_id_field_omitted() bool {
for i in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
mut ctx := TestStreamContext.new(writer)
data := generate_random_string(1, 30)
event := SSEEvent{
data: data
id: '' // Empty id field
}
ctx.write_sse(event) or {
println(' Iteration ${i}: write_sse failed: ${err}')
return false
}
content := writer.extract_sse_content()
// Should not contain "id: " prefix
if content.contains('id: ') {
println(' Iteration ${i}: Empty id field should not produce "id: " line')
println(' Got: "${content}"')
return false
}
}
return true
}
// Test 7g: Retry field is written when positive (Requirement 3.8)
// *For any* SSE event with retry > 0, output SHALL contain "retry: {value}\n"
fn test_property_7g_retry_field_when_positive() bool {
for i in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
mut ctx := TestStreamContext.new(writer)
retry := rand.int_in_range(100, 10000) or { 1000 }
data := generate_random_string(1, 30)
event := SSEEvent{
data: data
retry: retry
}
ctx.write_sse(event) or {
println(' Iteration ${i}: write_sse failed: ${err}')
return false
}
content := writer.extract_sse_content()
retry_line := 'retry: ${retry}\n'
// Retry line should exist
if !content.contains(retry_line) {
println(' Iteration ${i}: Retry line "${retry_line}" not found')
println(' Got: "${content}"')
return false
}
}
return true
}
// Test 7h: Retry field is omitted when zero (Requirement 3.8)
// *For any* SSE event with retry = 0, output SHALL NOT contain "retry: "
fn test_property_7h_zero_retry_field_omitted() bool {
for i in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
mut ctx := TestStreamContext.new(writer)
data := generate_random_string(1, 30)
event := SSEEvent{
data: data
retry: 0 // Zero retry
}
ctx.write_sse(event) or {
println(' Iteration ${i}: write_sse failed: ${err}')
return false
}
content := writer.extract_sse_content()
// Should not contain "retry: " prefix
if content.contains('retry: ') {
println(' Iteration ${i}: Zero retry should not produce "retry: " line')
println(' Got: "${content}"')
return false
}
}
return true
}
// Test 7i: Event separator (empty line) is always present (Requirement 3.9)
// *For any* SSE event, output SHALL end with an additional "\n" to separate events
fn test_property_7i_event_separator_present() bool {
for i in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
mut ctx := TestStreamContext.new(writer)
event := generate_random_sse_event()
ctx.write_sse(event) or {
println(' Iteration ${i}: write_sse failed: ${err}')
return false
}
content := writer.extract_sse_content()
// Content should end with double newline (data line + separator)
// The last line before separator should be data:, id:, or retry:
// followed by \n\n (the separator)
if !content.ends_with('\n\n') {
println(' Iteration ${i}: Event should end with double newline (separator)')
println(' Got: "${content}"')
println(' Last chars: ${content.bytes()[content.len - 4..]}')
return false
}
}
return true
}
// Test 7j: Complete SSE event with all fields (Requirements 3.4-3.9)
// *For any* SSE event with all fields set, output SHALL follow correct format and order
fn test_property_7j_complete_event_format() bool {
for i in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
mut ctx := TestStreamContext.new(writer)
event_type := generate_random_string(3, 15)
data := generate_random_string(1, 30)
id := generate_random_string(1, 10)
retry := rand.int_in_range(100, 10000) or { 1000 }
event := SSEEvent{
data: data
event: event_type
id: id
retry: retry
}
ctx.write_sse(event) or {
println(' Iteration ${i}: write_sse failed: ${err}')
return false
}
content := writer.extract_sse_content()
// Verify all fields are present
if !content.contains('event: ${event_type}\n') {
println(' Iteration ${i}: Event field missing')
return false
}
if !content.contains('data: ${data}\n') {
println(' Iteration ${i}: Data field missing')
return false
}
if !content.contains('id: ${id}\n') {
println(' Iteration ${i}: ID field missing')
return false
}
if !content.contains('retry: ${retry}\n') {
println(' Iteration ${i}: Retry field missing')
return false
}
// Verify order: event -> data -> id -> retry -> separator
event_pos := content.index('event: ') or { -1 }
data_pos := content.index('data: ') or { -1 }
id_pos := content.index('id: ') or { -1 }
retry_pos := content.index('retry: ') or { -1 }
if event_pos >= data_pos {
println(' Iteration ${i}: Event should come before data')
return false
}
if data_pos >= id_pos {
println(' Iteration ${i}: Data should come before id')
return false
}
if id_pos >= retry_pos {
println(' Iteration ${i}: ID should come before retry')
return false
}
// Verify ends with separator
if !content.ends_with('\n\n') {
println(' Iteration ${i}: Should end with event separator')
return false
}
}
return true
}
// Test 7k: Random SSE events are formatted correctly (Requirements 3.4-3.9)
// *For any* randomly generated SSE event, the formatting SHALL be correct
fn test_property_7k_random_events_formatted_correctly() bool {
for i in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
mut ctx := TestStreamContext.new(writer)
event := generate_random_sse_event()
ctx.write_sse(event) or {
println(' Iteration ${i}: write_sse failed: ${err}')
return false
}
content := writer.extract_sse_content()
// Data field should always be present
lines := event.data.split('\n')
for line in lines {
if !content.contains('data: ${line}\n') {
println(' Iteration ${i}: Data line missing: "data: ${line}"')
return false
}
}
// Event field should be present only if non-empty
if event.event.len > 0 {
if !content.contains('event: ${event.event}\n') {
println(' Iteration ${i}: Event field should be present')
return false
}
} else {
if content.contains('event: ') {
println(' Iteration ${i}: Event field should not be present when empty')
return false
}
}
// ID field should be present only if non-empty
if event.id.len > 0 {
if !content.contains('id: ${event.id}\n') {
println(' Iteration ${i}: ID field should be present')
return false
}
} else {
if content.contains('id: ') {
println(' Iteration ${i}: ID field should not be present when empty')
return false
}
}
// Retry field should be present only if > 0
if event.retry > 0 {
if !content.contains('retry: ${event.retry}\n') {
println(' Iteration ${i}: Retry field should be present')
return false
}
} else {
if content.contains('retry: ') {
println(' Iteration ${i}: Retry field should not be present when zero')
return false
}
}
// Should always end with separator
if !content.ends_with('\n\n') {
println(' Iteration ${i}: Should end with event separator')
return false
}
}
return true
}
fn main() {
println('🚀 开始 stream_sse() 函数属性测试...')
println('每个属性测试运行 ${test_iterations} 次迭代\n')
mut stats := PropertyTestStats{}
// Run Property 6 tests
// Feature: sse-streaming-helper, Property 6: streamSSE() 设置正确的 HTTP 头
// Validates: Requirements 3.1, 3.2, 3.3
println('--- Property 6: streamSSE() 设置正确的 HTTP 头 ---')
stats.run_property_test('Property 6a: Content-Type header (text/event-stream)', test_property_6a_content_type_header)
stats.run_property_test('Property 6b: Cache-Control header (no-cache)', test_property_6b_cache_control_header)
stats.run_property_test('Property 6c: Connection header (keep-alive)', test_property_6c_connection_header)
stats.run_property_test('Property 6d: Transfer-Encoding header (chunked)', test_property_6d_transfer_encoding_header)
stats.run_property_test('Property 6e: Headers consistency', test_property_6e_headers_consistency)
stats.run_property_test('Property 6f: All required headers present', test_property_6f_all_required_headers_present)
stats.run_property_test('Property 6g: SSE headers differ from text', test_property_6g_sse_headers_differ_from_text)
// Run Property 7 tests
// Feature: sse-streaming-helper, Property 7: SSE 事件格式化正确性
// Validates: Requirements 3.4, 3.5, 3.6, 3.7, 3.8, 3.9
println('\n--- Property 7: SSE 事件格式化正确性 ---')
stats.run_property_test('Property 7a: Data field formatting', test_property_7a_data_field_formatting)
stats.run_property_test('Property 7b: Multi-line data formatting', test_property_7b_multiline_data_formatting)
stats.run_property_test('Property 7c: Event field before data', test_property_7c_event_field_before_data)
stats.run_property_test('Property 7d: Empty event field omitted', test_property_7d_empty_event_field_omitted)
stats.run_property_test('Property 7e: ID field after data', test_property_7e_id_field_after_data)
stats.run_property_test('Property 7f: Empty ID field omitted', test_property_7f_empty_id_field_omitted)
stats.run_property_test('Property 7g: Retry field when positive', test_property_7g_retry_field_when_positive)
stats.run_property_test('Property 7h: Zero retry field omitted', test_property_7h_zero_retry_field_omitted)
stats.run_property_test('Property 7i: Event separator present', test_property_7i_event_separator_present)
stats.run_property_test('Property 7j: Complete event format', test_property_7j_complete_event_format)
stats.run_property_test('Property 7k: Random events formatted correctly', test_property_7k_random_events_formatted_correctly)
// Print summary
stats.print_summary()
}
+648
View File
@@ -0,0 +1,648 @@
// 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()
}
@@ -0,0 +1,624 @@
// test_usockets_stream_writer_properties.v
// Property-Based Testing for UsocketsStreamWriter chunked encoding
// Feature: sse-streaming-helper, Property 10: Chunked 编码格式正确性
// Validates: Requirements 7.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=== UsocketsStreamWriter 属性测试总结 ===')
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)
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
fn parse_chunked_data(chunked string) !([]u8, bool) {
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}')
}
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()
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
}
// ============================================================================
// Mock Socket for Testing UsocketsStreamWriter
// ============================================================================
// MockSocket - A mock socket that captures written data for testing
struct MockSocket {
mut:
buffer string
is_closed bool
}
fn MockSocket.new() MockSocket {
return MockSocket{
buffer: ''
is_closed: false
}
}
fn (mut s MockSocket) write_bytes(data string) int {
if s.is_closed {
return 0
}
s.buffer += data
return data.len
}
fn (s MockSocket) get_buffer() string {
return s.buffer
}
fn (mut s MockSocket) clear() {
s.buffer = ''
}
// ============================================================================
// UsocketsStreamWriter Simulation for Testing
// Uses the same chunked encoding logic as the real implementation
// ============================================================================
struct TestUsocketsStreamWriter {
mut:
socket &MockSocket
connected bool
}
fn TestUsocketsStreamWriter.new(socket &MockSocket) TestUsocketsStreamWriter {
return TestUsocketsStreamWriter{
socket: unsafe { socket }
connected: true
}
}
fn (mut w TestUsocketsStreamWriter) write(data []u8) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
chunk_header := format_chunk_size(data.len)
w.socket.write_bytes(chunk_header)
w.socket.write_bytes(data.bytestr())
w.socket.write_bytes('\r\n')
}
fn (mut w TestUsocketsStreamWriter) write_string(data string) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
chunk_header := format_chunk_size(data.len)
w.socket.write_bytes(chunk_header)
w.socket.write_bytes(data)
w.socket.write_bytes('\r\n')
}
fn (mut w TestUsocketsStreamWriter) close() ! {
if w.connected {
w.socket.write_bytes('0\r\n\r\n')
w.connected = false
}
}
fn (w TestUsocketsStreamWriter) is_connected() bool {
return w.connected
}
// ============================================================================
// Property 10: Chunked 编码格式正确性
// Feature: sse-streaming-helper, Property 10: Chunked 编码格式正确性
// Validates: Requirements 7.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: UsocketsStreamWriter.write produces valid chunked encoding for bytes
fn test_property_10a_usockets_write_bytes() bool {
rand.seed([u32(time.now().unix()), u32(12345)])
for i in 0 .. test_iterations {
mut mock_socket := MockSocket.new()
mut writer := TestUsocketsStreamWriter.new(&mock_socket)
// Generate random data
data := generate_random_bytes(1000)
// Write using the writer
writer.write(data) or {
println(' Iteration ${i}: Write failed: ${err}')
return false
}
// Get the output
output := mock_socket.get_buffer()
// Verify chunk format
mut crlf_pos := -1
for j := 0; j < output.len - 1; j++ {
if output[j] == `\r` && output[j + 1] == `\n` {
crlf_pos = j
break
}
}
if crlf_pos == -1 {
println(' Iteration ${i}: No CRLF found in chunk header')
return false
}
size_hex := output[..crlf_pos]
parsed_size := parse_hex(size_hex) or {
println(' Iteration ${i}: Invalid hex size: ${size_hex}')
return false
}
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(output) 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: UsocketsStreamWriter.write_string produces valid chunked encoding
fn test_property_10b_usockets_write_string() bool {
rand.seed([u32(time.now().unix()), u32(54321)])
for i in 0 .. test_iterations {
mut mock_socket := MockSocket.new()
mut writer := TestUsocketsStreamWriter.new(&mock_socket)
// Generate random string
data := generate_random_string(500)
// Write using the writer
writer.write_string(data) or {
println(' Iteration ${i}: Write failed: ${err}')
return false
}
// Get the output
output := mock_socket.get_buffer()
// Verify chunk format
mut crlf_pos := -1
for j := 0; j < output.len - 1; j++ {
if output[j] == `\r` && output[j + 1] == `\n` {
crlf_pos = j
break
}
}
if crlf_pos == -1 {
println(' Iteration ${i}: No CRLF found in chunk header')
return false
}
size_hex := output[..crlf_pos]
parsed_size := parse_hex(size_hex) or {
println(' Iteration ${i}: Invalid hex size: ${size_hex}')
return false
}
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(output) 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: UsocketsStreamWriter.close produces correct final chunk
fn test_property_10c_usockets_close_final_chunk() bool {
mut mock_socket := MockSocket.new()
mut writer := TestUsocketsStreamWriter.new(&mock_socket)
// Close the writer
writer.close() or {
println(' Close failed: ${err}')
return false
}
// Get the output
output := mock_socket.get_buffer()
// Final chunk must be exactly "0\r\n\r\n"
expected := '0\r\n\r\n'
if output != expected {
println(' Final chunk mismatch - expected "${expected}", got "${output}"')
return false
}
// Verify writer is now disconnected
if writer.is_connected() {
println(' Writer should be disconnected after close')
return false
}
return true
}
// Test 10d: UsocketsStreamWriter handles empty data correctly
fn test_property_10d_usockets_empty_data() bool {
mut mock_socket := MockSocket.new()
mut writer := TestUsocketsStreamWriter.new(&mock_socket)
// Write empty byte array
writer.write([]u8{}) or {
println(' Empty write failed: ${err}')
return false
}
// Buffer should be empty (no chunk for empty data)
if mock_socket.get_buffer() != '' {
println(' Empty bytes should produce no output, got "${mock_socket.get_buffer()}"')
return false
}
// Write empty string
writer.write_string('') or {
println(' Empty string write failed: ${err}')
return false
}
// Buffer should still be empty
if mock_socket.get_buffer() != '' {
println(' Empty string should produce no output, got "${mock_socket.get_buffer()}"')
return false
}
return true
}
// Test 10e: UsocketsStreamWriter rejects writes after close
fn test_property_10e_usockets_write_after_close() bool {
mut mock_socket := MockSocket.new()
mut writer := TestUsocketsStreamWriter.new(&mock_socket)
// Close the writer
writer.close() or {
println(' Close failed: ${err}')
return false
}
mock_socket.clear()
// Try to write after close - should fail
writer.write([u8(1), 2, 3]) or {
// Expected error
return true
}
println(' Write after close should have failed')
return false
}
// Test 10f: UsocketsStreamWriter multiple writes produce valid chunks
fn test_property_10f_usockets_multiple_writes() bool {
rand.seed([u32(time.now().unix()), u32(99999)])
for i in 0 .. test_iterations {
mut mock_socket := MockSocket.new()
mut writer := TestUsocketsStreamWriter.new(&mock_socket)
// Generate multiple random data chunks
num_chunks := rand.int_in_range(2, 10) or { 3 }
mut all_data := [][]u8{}
for _ in 0 .. num_chunks {
data := generate_random_bytes(100)
all_data << data
writer.write(data) or {
println(' Iteration ${i}: Write failed: ${err}')
return false
}
}
// Close to add final chunk
writer.close() or {
println(' Iteration ${i}: Close failed: ${err}')
return false
}
// Parse all chunks from output
output := mock_socket.get_buffer()
mut pos := 0
mut chunk_idx := 0
for pos < output.len && chunk_idx < all_data.len {
// Find CRLF for size
mut crlf_pos := -1
for j := pos; j < output.len - 1; j++ {
if output[j] == `\r` && output[j + 1] == `\n` {
crlf_pos = j
break
}
}
if crlf_pos == -1 {
println(' Iteration ${i}: No CRLF found at chunk ${chunk_idx}')
return false
}
size_hex := output[pos..crlf_pos]
size := parse_hex(size_hex) or {
println(' Iteration ${i}: Invalid hex at chunk ${chunk_idx}')
return false
}
// Check for final chunk
if size == 0 {
break
}
// Verify size matches expected
if size != all_data[chunk_idx].len {
println(' Iteration ${i}: Size mismatch at chunk ${chunk_idx}')
return false
}
// Extract data
data_start := crlf_pos + 2
data_end := data_start + size
extracted := output[data_start..data_end].bytes()
if extracted != all_data[chunk_idx] {
println(' Iteration ${i}: Data mismatch at chunk ${chunk_idx}')
return false
}
// Move to next chunk (skip trailing CRLF)
pos = data_end + 2
chunk_idx++
}
// Verify all chunks were found
if chunk_idx != all_data.len {
println(' Iteration ${i}: Expected ${all_data.len} chunks, found ${chunk_idx}')
return false
}
// Verify final chunk exists
if !output.ends_with('0\r\n\r\n') {
println(' Iteration ${i}: Missing final chunk')
return false
}
}
return true
}
// Test 10g: Round-trip property for UsocketsStreamWriter
fn test_property_10g_usockets_roundtrip() bool {
rand.seed([u32(time.now().unix()), u32(77777)])
for i in 0 .. test_iterations {
mut mock_socket := MockSocket.new()
mut writer := TestUsocketsStreamWriter.new(&mock_socket)
// Generate random data
original := generate_random_bytes(500)
// Write using the writer
writer.write(original) or {
println(' Iteration ${i}: Write failed: ${err}')
return false
}
// Parse back
output := mock_socket.get_buffer()
recovered, _ := parse_chunked_data(output) 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('🚀 开始 UsocketsStreamWriter 属性测试...')
println('每个属性测试运行 ${test_iterations} 次迭代\n')
mut stats := PropertyTestStats{}
// Run Property 10 tests for UsocketsStreamWriter
// Feature: sse-streaming-helper, Property 10: Chunked 编码格式正确性
// Validates: Requirements 7.2
stats.run_property_test('Property 10a: UsocketsStreamWriter.write bytes encoding', test_property_10a_usockets_write_bytes)
stats.run_property_test('Property 10b: UsocketsStreamWriter.write_string encoding', test_property_10b_usockets_write_string)
stats.run_property_test('Property 10c: UsocketsStreamWriter.close final chunk', test_property_10c_usockets_close_final_chunk)
stats.run_property_test('Property 10d: UsocketsStreamWriter empty data handling', test_property_10d_usockets_empty_data)
stats.run_property_test('Property 10e: UsocketsStreamWriter write after close', test_property_10e_usockets_write_after_close)
stats.run_property_test('Property 10f: UsocketsStreamWriter multiple writes', test_property_10f_usockets_multiple_writes)
stats.run_property_test('Property 10g: UsocketsStreamWriter round-trip', test_property_10g_usockets_roundtrip)
// Print summary
stats.print_summary()
}