Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d31492fd59 | ||
|
|
e19f799e46 | ||
|
|
f7e753969d | ||
|
|
e9d14a35f6 | ||
|
|
9ae9170bcb |
+1
-1
@@ -2,7 +2,6 @@
|
|||||||
*.exe
|
*.exe
|
||||||
*.out
|
*.out
|
||||||
*.o
|
*.o
|
||||||
*.a
|
|
||||||
*.so
|
*.so
|
||||||
*.dll
|
*.dll
|
||||||
|
|
||||||
@@ -47,3 +46,4 @@ temp/
|
|||||||
chunks/
|
chunks/
|
||||||
|
|
||||||
.kiro
|
.kiro
|
||||||
|
*.def
|
||||||
@@ -19,6 +19,7 @@ A high-performance V language web framework inspired by [Hono.js](https://hono.d
|
|||||||
- 📤 **File Upload** - Chunked file upload support
|
- 📤 **File Upload** - Chunked file upload support
|
||||||
- 🗄️ **Database** - SQLite integration for data persistence
|
- 🗄️ **Database** - SQLite integration for data persistence
|
||||||
- 🔌 **WebSocket** - RFC 6455 compliant WebSocket support with event-based API
|
- 🔌 **WebSocket** - RFC 6455 compliant WebSocket support with event-based API
|
||||||
|
- 📡 **SSE Streaming** - Server-Sent Events and streaming response support
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -784,6 +785,251 @@ fn main() {
|
|||||||
| 1009 | `ws_close_message_too_big` | Message too big |
|
| 1009 | `ws_close_message_too_big` | Message too big |
|
||||||
| 1011 | `ws_close_internal_error` | Internal server error |
|
| 1011 | `ws_close_internal_error` | Internal server error |
|
||||||
|
|
||||||
|
### 9. SSE Streaming Helper
|
||||||
|
|
||||||
|
Server-Sent Events (SSE) and streaming response support for real-time data push.
|
||||||
|
|
||||||
|
#### Basic Binary Streaming
|
||||||
|
|
||||||
|
```v
|
||||||
|
import meiseayoung.hono
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
mut app := hono.Hono.new()
|
||||||
|
|
||||||
|
// Basic stream - binary data streaming
|
||||||
|
// Sets Transfer-Encoding: chunked header
|
||||||
|
app.get('/stream', fn (mut c hono.Context) http.Response {
|
||||||
|
return hono.c_stream(mut c, fn (mut stream hono.StreamContext) ! {
|
||||||
|
// Register abort callback for client disconnection
|
||||||
|
stream.on_abort(fn () {
|
||||||
|
println('Client disconnected')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Stream binary data in chunks
|
||||||
|
for i in 0 .. 5 {
|
||||||
|
data := 'Chunk ${i + 1}: Hello from stream!\n'
|
||||||
|
stream.write(data.bytes())!
|
||||||
|
stream.sleep(500) // 500ms delay between chunks
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.listen(':3000')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Text Streaming
|
||||||
|
|
||||||
|
```v
|
||||||
|
import meiseayoung.hono
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
mut app := hono.Hono.new()
|
||||||
|
|
||||||
|
// Text stream - for streaming text content
|
||||||
|
// Sets Content-Type: text/plain; charset=utf-8
|
||||||
|
// Sets Transfer-Encoding: chunked
|
||||||
|
// Sets X-Content-Type-Options: nosniff
|
||||||
|
app.get('/stream-text', fn (mut c hono.Context) http.Response {
|
||||||
|
return hono.c_stream_text(mut c, fn (mut stream hono.StreamContext) ! {
|
||||||
|
stream.writeln('=== Text Streaming Demo ===')!
|
||||||
|
stream.sleep(300)
|
||||||
|
|
||||||
|
for i in 0 .. 5 {
|
||||||
|
stream.write_string('Processing item ${i + 1}... ')!
|
||||||
|
stream.sleep(200)
|
||||||
|
stream.writeln('Done!')!
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.listen(':3000')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Server-Sent Events (SSE)
|
||||||
|
|
||||||
|
```v
|
||||||
|
import meiseayoung.hono
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
mut app := hono.Hono.new()
|
||||||
|
|
||||||
|
// SSE stream - for Server-Sent Events
|
||||||
|
// Sets Content-Type: text/event-stream
|
||||||
|
// Sets Cache-Control: no-cache
|
||||||
|
// Sets Connection: keep-alive
|
||||||
|
app.get('/sse', fn (mut c hono.Context) http.Response {
|
||||||
|
return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
|
||||||
|
// Send initial connection event
|
||||||
|
stream.write_sse(hono.SSEEvent{
|
||||||
|
data: 'Connected to SSE stream'
|
||||||
|
event: 'connect'
|
||||||
|
id: '0'
|
||||||
|
})!
|
||||||
|
|
||||||
|
// Send periodic updates
|
||||||
|
for i in 1 .. 6 {
|
||||||
|
stream.sleep(1000) // 1 second delay
|
||||||
|
|
||||||
|
stream.write_sse(hono.SSEEvent{
|
||||||
|
data: 'Update ${i}'
|
||||||
|
event: 'update'
|
||||||
|
id: '${i}'
|
||||||
|
})!
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send completion event
|
||||||
|
stream.write_sse(hono.SSEEvent{
|
||||||
|
data: 'Stream completed'
|
||||||
|
event: 'complete'
|
||||||
|
id: '999'
|
||||||
|
})!
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.listen(':3000')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### SSE with Multi-line Data
|
||||||
|
|
||||||
|
```v
|
||||||
|
import meiseayoung.hono
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
mut app := hono.Hono.new()
|
||||||
|
|
||||||
|
app.get('/sse-json', fn (mut c hono.Context) http.Response {
|
||||||
|
return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
|
||||||
|
// Send JSON data (multi-line formatted)
|
||||||
|
json_data := '{\n "name": "v-hono",\n "version": "1.0.0"\n}'
|
||||||
|
stream.write_sse(hono.SSEEvent{
|
||||||
|
data: json_data
|
||||||
|
event: 'json'
|
||||||
|
id: '1'
|
||||||
|
})!
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.listen(':3000')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### SSE with Error Handling
|
||||||
|
|
||||||
|
```v
|
||||||
|
import meiseayoung.hono
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
mut app := hono.Hono.new()
|
||||||
|
|
||||||
|
app.get('/sse-error', fn (mut c hono.Context) http.Response {
|
||||||
|
return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
|
||||||
|
stream.write_sse(hono.SSEEvent{
|
||||||
|
data: 'Starting...'
|
||||||
|
event: 'start'
|
||||||
|
})!
|
||||||
|
|
||||||
|
// Simulate an error
|
||||||
|
return error('Something went wrong')
|
||||||
|
}, fn (err IError, mut stream hono.StreamContext) {
|
||||||
|
// Custom error handler
|
||||||
|
stream.write_sse(hono.SSEEvent{
|
||||||
|
data: 'Error: ${err.msg()}'
|
||||||
|
event: 'error'
|
||||||
|
}) or {}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.listen(':3000')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### SSE with Retry Field
|
||||||
|
|
||||||
|
```v
|
||||||
|
import meiseayoung.hono
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
mut app := hono.Hono.new()
|
||||||
|
|
||||||
|
app.get('/sse-retry', fn (mut c hono.Context) http.Response {
|
||||||
|
return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
|
||||||
|
// Send event with retry field (client reconnects after 3 seconds)
|
||||||
|
stream.write_sse(hono.SSEEvent{
|
||||||
|
data: 'This event includes a retry field'
|
||||||
|
event: 'message'
|
||||||
|
id: '1'
|
||||||
|
retry: 3000 // 3 seconds
|
||||||
|
})!
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.listen(':3000')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### StreamContext Methods
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `write(data []u8)` | Write raw bytes to stream |
|
||||||
|
| `write_string(data string)` | Write string to stream |
|
||||||
|
| `writeln(data string)` | Write string with newline |
|
||||||
|
| `write_sse(event SSEEvent)` | Write SSE event |
|
||||||
|
| `sleep(ms int)` | Pause execution for milliseconds |
|
||||||
|
| `pipe(data []u8)` | Pipe data to stream |
|
||||||
|
| `on_abort(callback fn())` | Register abort callback |
|
||||||
|
| `close()` | Close the stream |
|
||||||
|
| `is_open()` | Check if stream is still open |
|
||||||
|
|
||||||
|
#### SSEEvent Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `data` | `string` | Yes | Event data (supports multi-line) |
|
||||||
|
| `event` | `string` | No | Event type name |
|
||||||
|
| `id` | `string` | No | Event ID for client reconnection |
|
||||||
|
| `retry` | `int` | No | Reconnection interval in milliseconds |
|
||||||
|
|
||||||
|
#### Streaming Functions
|
||||||
|
|
||||||
|
| Function | Headers Set | Use Case |
|
||||||
|
|----------|-------------|----------|
|
||||||
|
| `c_stream()` | `Transfer-Encoding: chunked` | Binary data streaming |
|
||||||
|
| `c_stream_text()` | `Content-Type: text/plain`, `Transfer-Encoding: chunked`, `X-Content-Type-Options: nosniff` | Text streaming |
|
||||||
|
| `c_stream_sse()` | `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive` | Server-Sent Events |
|
||||||
|
|
||||||
|
#### Client-Side JavaScript Example
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script>
|
||||||
|
// Connect to SSE endpoint
|
||||||
|
const eventSource = new EventSource('/sse');
|
||||||
|
|
||||||
|
// Listen for specific event types
|
||||||
|
eventSource.addEventListener('connect', (e) => {
|
||||||
|
console.log('Connected:', e.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener('update', (e) => {
|
||||||
|
console.log('Update:', e.data, 'ID:', e.lastEventId);
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener('complete', (e) => {
|
||||||
|
console.log('Complete:', e.data);
|
||||||
|
eventSource.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle errors
|
||||||
|
eventSource.onerror = (e) => {
|
||||||
|
console.error('SSE Error:', e);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
## Route Grouping
|
## Route Grouping
|
||||||
|
|
||||||
```v
|
```v
|
||||||
@@ -889,8 +1135,9 @@ v-hono/
|
|||||||
├── rate_limit.v # Rate limiting middleware
|
├── rate_limit.v # Rate limiting middleware
|
||||||
├── validator.v # Request validation
|
├── validator.v # Request validation
|
||||||
├── websocket.v # WebSocket helper (RFC 6455)
|
├── websocket.v # WebSocket helper (RFC 6455)
|
||||||
├── picoev_server.v # Picoev backend with WebSocket support
|
├── streaming.v # SSE streaming helper
|
||||||
├── usockets_server.v # uSockets backend with WebSocket support
|
├── picoev_server.v # Picoev backend with WebSocket/SSE support
|
||||||
|
├── usockets_server.v # uSockets backend with WebSocket/SSE support
|
||||||
├── v.mod # Module definition
|
├── v.mod # Module definition
|
||||||
├── README.md # Documentation
|
├── README.md # Documentation
|
||||||
├── examples/ # Example applications
|
├── examples/ # Example applications
|
||||||
|
|||||||
@@ -0,0 +1,554 @@
|
|||||||
|
// SSE Streaming Demo - Server-Sent Events Example for v-hono
|
||||||
|
// This example demonstrates all SSE streaming helper functions:
|
||||||
|
// - stream() - Basic binary streaming
|
||||||
|
// - stream_text() - Text streaming
|
||||||
|
// - stream_sse() - Server-Sent Events streaming
|
||||||
|
//
|
||||||
|
// Run with: v -enable-globals run examples/sse_demo.v
|
||||||
|
// Then open a browser and navigate to http://127.0.0.1:3000
|
||||||
|
// Or use curl to test the endpoints:
|
||||||
|
// curl http://127.0.0.1:3000/stream
|
||||||
|
// curl http://127.0.0.1:3000/stream-text
|
||||||
|
// curl http://127.0.0.1:3000/sse
|
||||||
|
//
|
||||||
|
// Features demonstrated:
|
||||||
|
// - Basic binary streaming with stream()
|
||||||
|
// - Text streaming with stream_text()
|
||||||
|
// - SSE event streaming with stream_sse()
|
||||||
|
// - Error handling with custom error handlers
|
||||||
|
// - SSE event fields (data, event, id, retry)
|
||||||
|
// - Multi-line data in SSE events
|
||||||
|
// - Sleep/delay between stream writes
|
||||||
|
// - onAbort callback for client disconnection
|
||||||
|
module main
|
||||||
|
|
||||||
|
import net.http
|
||||||
|
import os
|
||||||
|
import hono
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
mut app := hono.Hono.new()
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Static HTML page for testing SSE
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
app.get('/', fn (mut c hono.Context) http.Response {
|
||||||
|
return c.html(get_sse_test_page())
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Basic Binary Streaming Example
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
// Basic stream - demonstrates binary data streaming
|
||||||
|
// Sets Transfer-Encoding: chunked header
|
||||||
|
app.get('/stream', fn (mut c hono.Context) http.Response {
|
||||||
|
return hono.c_stream(mut c, fn (mut stream hono.StreamContext) ! {
|
||||||
|
// Register abort callback for client disconnection
|
||||||
|
stream.on_abort(fn () {
|
||||||
|
println('[Stream] Client disconnected')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Stream binary data in chunks
|
||||||
|
for i in 0 .. 5 {
|
||||||
|
data := 'Chunk ${i + 1}: Hello from binary stream!\n'
|
||||||
|
stream.write(data.bytes())!
|
||||||
|
stream.sleep(500) // 500ms delay between chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.write('Stream complete!\n'.bytes())!
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Text Streaming Example
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
// Text stream - demonstrates text data streaming
|
||||||
|
// Sets Content-Type: text/plain; charset=utf-8
|
||||||
|
// Sets Transfer-Encoding: chunked
|
||||||
|
// Sets X-Content-Type-Options: nosniff
|
||||||
|
app.get('/stream-text', fn (mut c hono.Context) http.Response {
|
||||||
|
return hono.c_stream_text(mut c, fn (mut stream hono.StreamContext) ! {
|
||||||
|
// Register abort callback
|
||||||
|
stream.on_abort(fn () {
|
||||||
|
println('[StreamText] Client disconnected')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Stream text data with writeln (adds newline automatically)
|
||||||
|
stream.writeln('=== Text Streaming Demo ===')!
|
||||||
|
stream.sleep(300)
|
||||||
|
|
||||||
|
for i in 0 .. 5 {
|
||||||
|
stream.write_string('Processing item ${i + 1}... ')!
|
||||||
|
stream.sleep(200)
|
||||||
|
stream.writeln('Done!')!
|
||||||
|
stream.sleep(300)
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.writeln('')!
|
||||||
|
stream.writeln('All items processed successfully!')!
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// SSE (Server-Sent Events) Streaming Example
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
// SSE stream - demonstrates Server-Sent Events
|
||||||
|
// Sets Content-Type: text/event-stream
|
||||||
|
// Sets Cache-Control: no-cache
|
||||||
|
// Sets Connection: keep-alive
|
||||||
|
app.get('/sse', fn (mut c hono.Context) http.Response {
|
||||||
|
return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
|
||||||
|
// Register abort callback
|
||||||
|
stream.on_abort(fn () {
|
||||||
|
println('[SSE] Client disconnected')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Send initial connection event
|
||||||
|
stream.write_sse(hono.SSEEvent{
|
||||||
|
data: 'Connected to SSE stream'
|
||||||
|
event: 'connect'
|
||||||
|
id: '0'
|
||||||
|
})!
|
||||||
|
|
||||||
|
// Send periodic updates
|
||||||
|
for i in 1 .. 6 {
|
||||||
|
stream.sleep(1000) // 1 second delay
|
||||||
|
|
||||||
|
stream.write_sse(hono.SSEEvent{
|
||||||
|
data: 'Update ${i}: Current time is ${get_timestamp()}'
|
||||||
|
event: 'update'
|
||||||
|
id: '${i}'
|
||||||
|
})!
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send completion event
|
||||||
|
stream.write_sse(hono.SSEEvent{
|
||||||
|
data: 'Stream completed'
|
||||||
|
event: 'complete'
|
||||||
|
id: '999'
|
||||||
|
})!
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// SSE with Multi-line Data Example
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
// SSE with multi-line data - demonstrates multi-line data handling
|
||||||
|
app.get('/sse-multiline', fn (mut c hono.Context) http.Response {
|
||||||
|
return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
|
||||||
|
// Send event with multi-line data
|
||||||
|
stream.write_sse(hono.SSEEvent{
|
||||||
|
data: 'Line 1: First line of data\nLine 2: Second line of data\nLine 3: Third line of data'
|
||||||
|
event: 'multiline'
|
||||||
|
id: '1'
|
||||||
|
})!
|
||||||
|
|
||||||
|
stream.sleep(1000)
|
||||||
|
|
||||||
|
// Send JSON data (multi-line formatted)
|
||||||
|
json_data := '{\n "name": "v-hono",\n "version": "1.0.0",\n "features": ["SSE", "WebSocket", "Streaming"]\n}'
|
||||||
|
stream.write_sse(hono.SSEEvent{
|
||||||
|
data: json_data
|
||||||
|
event: 'json'
|
||||||
|
id: '2'
|
||||||
|
})!
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// SSE with Retry Field Example
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
// SSE with retry - demonstrates retry field for reconnection
|
||||||
|
app.get('/sse-retry', fn (mut c hono.Context) http.Response {
|
||||||
|
return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
|
||||||
|
// Send event with retry field (tells client to reconnect after 3 seconds)
|
||||||
|
stream.write_sse(hono.SSEEvent{
|
||||||
|
data: 'This event includes a retry field'
|
||||||
|
event: 'message'
|
||||||
|
id: '1'
|
||||||
|
retry: 3000 // 3 seconds
|
||||||
|
})!
|
||||||
|
|
||||||
|
stream.sleep(1000)
|
||||||
|
|
||||||
|
// Send another event without retry
|
||||||
|
stream.write_sse(hono.SSEEvent{
|
||||||
|
data: 'This event does not include retry'
|
||||||
|
event: 'message'
|
||||||
|
id: '2'
|
||||||
|
})!
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// SSE with Error Handling Example
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
// SSE with error handling - demonstrates custom error handler
|
||||||
|
app.get('/sse-error', fn (mut c hono.Context) http.Response {
|
||||||
|
return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
|
||||||
|
// Send initial event
|
||||||
|
stream.write_sse(hono.SSEEvent{
|
||||||
|
data: 'Starting stream with potential error...'
|
||||||
|
event: 'start'
|
||||||
|
id: '1'
|
||||||
|
})!
|
||||||
|
|
||||||
|
stream.sleep(1000)
|
||||||
|
|
||||||
|
// Simulate an error condition
|
||||||
|
// In real applications, this could be a database error, API failure, etc.
|
||||||
|
should_error := true
|
||||||
|
if should_error {
|
||||||
|
return error('Simulated error for demonstration')
|
||||||
|
}
|
||||||
|
|
||||||
|
// This won't be reached due to the error above
|
||||||
|
stream.write_sse(hono.SSEEvent{
|
||||||
|
data: 'This message will not be sent'
|
||||||
|
event: 'message'
|
||||||
|
id: '2'
|
||||||
|
})!
|
||||||
|
}, fn (err IError, mut stream hono.StreamContext) {
|
||||||
|
// Custom error handler - send error event to client
|
||||||
|
println('[SSE Error Handler] Error occurred: ${err.msg()}')
|
||||||
|
|
||||||
|
// Try to send error event to client before closing
|
||||||
|
stream.write_sse(hono.SSEEvent{
|
||||||
|
data: 'Error: ${err.msg()}'
|
||||||
|
event: 'error'
|
||||||
|
id: 'error'
|
||||||
|
}) or {
|
||||||
|
println('[SSE Error Handler] Failed to send error event: ${err}')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Real-time Counter Example
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
// Real-time counter - demonstrates continuous SSE updates
|
||||||
|
app.get('/sse-counter', fn (mut c hono.Context) http.Response {
|
||||||
|
return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
|
||||||
|
mut counter := 0
|
||||||
|
|
||||||
|
// Send counter updates every 500ms for 20 iterations
|
||||||
|
for _ in 0 .. 20 {
|
||||||
|
if !stream.is_open() {
|
||||||
|
println('[Counter] Stream closed, stopping')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
counter++
|
||||||
|
stream.write_sse(hono.SSEEvent{
|
||||||
|
data: '${counter}'
|
||||||
|
event: 'counter'
|
||||||
|
id: '${counter}'
|
||||||
|
})!
|
||||||
|
|
||||||
|
stream.sleep(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send completion event
|
||||||
|
stream.write_sse(hono.SSEEvent{
|
||||||
|
data: 'Counter finished at ${counter}'
|
||||||
|
event: 'complete'
|
||||||
|
id: 'done'
|
||||||
|
})!
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Pipe Example - Stream file content
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
// Pipe example - demonstrates piping data to stream
|
||||||
|
app.get('/stream-pipe', fn (mut c hono.Context) http.Response {
|
||||||
|
return hono.c_stream(mut c, fn (mut stream hono.StreamContext) ! {
|
||||||
|
// Create some sample data to pipe
|
||||||
|
sample_data := 'This is sample data that will be piped to the stream.\n'.repeat(5)
|
||||||
|
|
||||||
|
// Pipe the data in chunks
|
||||||
|
chunk_size := 50
|
||||||
|
mut offset := 0
|
||||||
|
|
||||||
|
for offset < sample_data.len {
|
||||||
|
end := if offset + chunk_size > sample_data.len { sample_data.len } else { offset + chunk_size }
|
||||||
|
chunk := sample_data[offset..end]
|
||||||
|
stream.pipe(chunk.bytes())!
|
||||||
|
offset = end
|
||||||
|
stream.sleep(100)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// REST API endpoints for demonstration
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
app.get('/api/status', fn (mut c hono.Context) http.Response {
|
||||||
|
return c.json('{"status":"running","streaming_endpoints":["/stream","/stream-text","/sse","/sse-multiline","/sse-retry","/sse-error","/sse-counter","/stream-pipe"]}')
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Start the server
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
println('============================================================')
|
||||||
|
println('SSE Streaming Demo Server')
|
||||||
|
println('============================================================')
|
||||||
|
println('')
|
||||||
|
println('Server starting on http://127.0.0.1:3000')
|
||||||
|
println('')
|
||||||
|
println('Streaming Endpoints:')
|
||||||
|
println(' http://127.0.0.1:3000/stream - Basic binary streaming')
|
||||||
|
println(' http://127.0.0.1:3000/stream-text - Text streaming')
|
||||||
|
println(' http://127.0.0.1:3000/sse - SSE event streaming')
|
||||||
|
println(' http://127.0.0.1:3000/sse-multiline - SSE with multi-line data')
|
||||||
|
println(' http://127.0.0.1:3000/sse-retry - SSE with retry field')
|
||||||
|
println(' http://127.0.0.1:3000/sse-error - SSE with error handling')
|
||||||
|
println(' http://127.0.0.1:3000/sse-counter - Real-time counter')
|
||||||
|
println(' http://127.0.0.1:3000/stream-pipe - Pipe data to stream')
|
||||||
|
println('')
|
||||||
|
println('HTTP Endpoints:')
|
||||||
|
println(' http://127.0.0.1:3000/ - Test page')
|
||||||
|
println(' http://127.0.0.1:3000/api/status - Server status')
|
||||||
|
println('')
|
||||||
|
println('Test with curl:')
|
||||||
|
println(' curl http://127.0.0.1:3000/stream')
|
||||||
|
println(' curl http://127.0.0.1:3000/stream-text')
|
||||||
|
println(' curl http://127.0.0.1:3000/sse')
|
||||||
|
println('')
|
||||||
|
println('============================================================')
|
||||||
|
|
||||||
|
app.listen(':3000')
|
||||||
|
}
|
||||||
|
|
||||||
|
// get_timestamp - Get current timestamp string
|
||||||
|
fn get_timestamp() string {
|
||||||
|
return '${os.execute("echo %TIME%").output.trim_space()}'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// get_sse_test_page - Returns HTML test page for SSE streaming
|
||||||
|
fn get_sse_test_page() string {
|
||||||
|
sq := "'"
|
||||||
|
return '<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>v-hono SSE Streaming Demo</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 1000px; margin: 0 auto; padding: 20px; background: #f5f5f5; }
|
||||||
|
h1 { color: #667eea; margin-bottom: 10px; }
|
||||||
|
h2 { color: #333; font-size: 1.2em; margin: 20px 0 10px; }
|
||||||
|
.subtitle { color: #666; margin-bottom: 30px; }
|
||||||
|
.card { background: white; padding: 20px; margin: 15px 0; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||||
|
.endpoint { display: flex; align-items: center; gap: 10px; margin: 10px 0; flex-wrap: wrap; }
|
||||||
|
.endpoint-url { font-family: monospace; background: #f0f0f0; padding: 8px 12px; border-radius: 4px; flex: 1; min-width: 200px; }
|
||||||
|
button { padding: 8px 16px; border-radius: 4px; border: none; cursor: pointer; font-size: 14px; transition: background 0.2s; }
|
||||||
|
.btn-connect { background: #667eea; color: white; }
|
||||||
|
.btn-connect:hover { background: #5a6fd6; }
|
||||||
|
.btn-disconnect { background: #e74c3c; color: white; }
|
||||||
|
.btn-disconnect:hover { background: #c0392b; }
|
||||||
|
.btn-clear { background: #95a5a6; color: white; }
|
||||||
|
.btn-clear:hover { background: #7f8c8d; }
|
||||||
|
.status { padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: bold; }
|
||||||
|
.status-connected { background: #27ae60; color: white; }
|
||||||
|
.status-disconnected { background: #e74c3c; color: white; }
|
||||||
|
.status-connecting { background: #f39c12; color: white; }
|
||||||
|
.output { height: 200px; overflow-y: auto; background: #1e1e1e; color: #d4d4d4; padding: 15px; border-radius: 4px; font-family: monospace; font-size: 13px; line-height: 1.5; }
|
||||||
|
.output:empty::before { content: "No messages yet..."; color: #666; }
|
||||||
|
.msg { margin: 2px 0; padding: 2px 0; }
|
||||||
|
.msg-event { color: #569cd6; }
|
||||||
|
.msg-data { color: #ce9178; }
|
||||||
|
.msg-id { color: #b5cea8; }
|
||||||
|
.msg-retry { color: #dcdcaa; }
|
||||||
|
.msg-system { color: #6a9955; font-style: italic; }
|
||||||
|
.msg-error { color: #f44747; }
|
||||||
|
.msg-text { color: #d4d4d4; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); gap: 20px; }
|
||||||
|
@media (max-width: 600px) { .grid { grid-template-columns: 1fr; } .endpoint { flex-direction: column; align-items: stretch; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>v-hono SSE Streaming Demo</h1>
|
||||||
|
<p class="subtitle">Test Server-Sent Events and streaming responses</p>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<h2>SSE Event Stream</h2>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-url">/sse</span>
|
||||||
|
<button class="btn-connect" onclick="connectSSE(${sq}/sse${sq}, ${sq}sse-output${sq}, ${sq}sse-status${sq})">Connect</button>
|
||||||
|
<button class="btn-disconnect" onclick="disconnectSSE(${sq}sse${sq})">Disconnect</button>
|
||||||
|
<span id="sse-status" class="status status-disconnected">Disconnected</span>
|
||||||
|
</div>
|
||||||
|
<div id="sse-output" class="output"></div>
|
||||||
|
<button class="btn-clear" onclick="clearOutput(${sq}sse-output${sq})">Clear</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Multi-line SSE</h2>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-url">/sse-multiline</span>
|
||||||
|
<button class="btn-connect" onclick="connectSSE(${sq}/sse-multiline${sq}, ${sq}multiline-output${sq}, ${sq}multiline-status${sq})">Connect</button>
|
||||||
|
<button class="btn-disconnect" onclick="disconnectSSE(${sq}multiline${sq})">Disconnect</button>
|
||||||
|
<span id="multiline-status" class="status status-disconnected">Disconnected</span>
|
||||||
|
</div>
|
||||||
|
<div id="multiline-output" class="output"></div>
|
||||||
|
<button class="btn-clear" onclick="clearOutput(${sq}multiline-output${sq})">Clear</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Real-time Counter</h2>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-url">/sse-counter</span>
|
||||||
|
<button class="btn-connect" onclick="connectSSE(${sq}/sse-counter${sq}, ${sq}counter-output${sq}, ${sq}counter-status${sq})">Connect</button>
|
||||||
|
<button class="btn-disconnect" onclick="disconnectSSE(${sq}counter${sq})">Disconnect</button>
|
||||||
|
<span id="counter-status" class="status status-disconnected">Disconnected</span>
|
||||||
|
</div>
|
||||||
|
<div id="counter-output" class="output"></div>
|
||||||
|
<button class="btn-clear" onclick="clearOutput(${sq}counter-output${sq})">Clear</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Error Handling</h2>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-url">/sse-error</span>
|
||||||
|
<button class="btn-connect" onclick="connectSSE(${sq}/sse-error${sq}, ${sq}error-output${sq}, ${sq}error-status${sq})">Connect</button>
|
||||||
|
<button class="btn-disconnect" onclick="disconnectSSE(${sq}error${sq})">Disconnect</button>
|
||||||
|
<span id="error-status" class="status status-disconnected">Disconnected</span>
|
||||||
|
</div>
|
||||||
|
<div id="error-output" class="output"></div>
|
||||||
|
<button class="btn-clear" onclick="clearOutput(${sq}error-output${sq})">Clear</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Text Stream</h2>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-url">/stream-text</span>
|
||||||
|
<button class="btn-connect" onclick="fetchStream(${sq}/stream-text${sq}, ${sq}text-output${sq}, ${sq}text-status${sq})">Fetch</button>
|
||||||
|
<span id="text-status" class="status status-disconnected">Ready</span>
|
||||||
|
</div>
|
||||||
|
<div id="text-output" class="output"></div>
|
||||||
|
<button class="btn-clear" onclick="clearOutput(${sq}text-output${sq})">Clear</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Binary Stream</h2>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-url">/stream</span>
|
||||||
|
<button class="btn-connect" onclick="fetchStream(${sq}/stream${sq}, ${sq}binary-output${sq}, ${sq}binary-status${sq})">Fetch</button>
|
||||||
|
<span id="binary-status" class="status status-disconnected">Ready</span>
|
||||||
|
</div>
|
||||||
|
<div id="binary-output" class="output"></div>
|
||||||
|
<button class="btn-clear" onclick="clearOutput(${sq}binary-output${sq})">Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var connections = {};
|
||||||
|
|
||||||
|
function log(outputId, msg, type) {
|
||||||
|
var output = document.getElementById(outputId);
|
||||||
|
var div = document.createElement(${sq}div${sq});
|
||||||
|
div.className = ${sq}msg msg-${sq} + (type || ${sq}text${sq});
|
||||||
|
div.textContent = msg;
|
||||||
|
output.appendChild(div);
|
||||||
|
output.scrollTop = output.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(statusId, status) {
|
||||||
|
var el = document.getElementById(statusId);
|
||||||
|
el.textContent = status;
|
||||||
|
el.className = ${sq}status status-${sq} + status.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearOutput(outputId) {
|
||||||
|
document.getElementById(outputId).innerHTML = ${sq}${sq};
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectSSE(url, outputId, statusId) {
|
||||||
|
var key = outputId.replace(${sq}-output${sq}, ${sq}${sq});
|
||||||
|
if (connections[key]) {
|
||||||
|
connections[key].close();
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(statusId, ${sq}Connecting${sq});
|
||||||
|
log(outputId, ${sq}Connecting to ${sq} + url + ${sq}...${sq}, ${sq}system${sq});
|
||||||
|
|
||||||
|
var es = new EventSource(url);
|
||||||
|
connections[key] = es;
|
||||||
|
|
||||||
|
es.onopen = function() {
|
||||||
|
setStatus(statusId, ${sq}Connected${sq});
|
||||||
|
log(outputId, ${sq}Connected!${sq}, ${sq}system${sq});
|
||||||
|
};
|
||||||
|
|
||||||
|
es.onmessage = function(e) {
|
||||||
|
log(outputId, ${sq}data: ${sq} + e.data, ${sq}data${sq});
|
||||||
|
};
|
||||||
|
|
||||||
|
es.onerror = function() {
|
||||||
|
setStatus(statusId, ${sq}Disconnected${sq});
|
||||||
|
log(outputId, ${sq}Connection closed${sq}, ${sq}system${sq});
|
||||||
|
connections[key] = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for custom event types
|
||||||
|
[${sq}connect${sq}, ${sq}update${sq}, ${sq}complete${sq}, ${sq}multiline${sq}, ${sq}json${sq}, ${sq}message${sq}, ${sq}counter${sq}, ${sq}start${sq}, ${sq}error${sq}].forEach(function(type) {
|
||||||
|
es.addEventListener(type, function(e) {
|
||||||
|
log(outputId, ${sq}event: ${sq} + type, ${sq}event${sq});
|
||||||
|
log(outputId, ${sq}data: ${sq} + e.data, ${sq}data${sq});
|
||||||
|
if (e.lastEventId) log(outputId, ${sq}id: ${sq} + e.lastEventId, ${sq}id${sq});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectSSE(key) {
|
||||||
|
if (connections[key]) {
|
||||||
|
connections[key].close();
|
||||||
|
connections[key] = null;
|
||||||
|
setStatus(key + ${sq}-status${sq}, ${sq}Disconnected${sq});
|
||||||
|
log(key + ${sq}-output${sq}, ${sq}Disconnected by user${sq}, ${sq}system${sq});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStream(url, outputId, statusId) {
|
||||||
|
setStatus(statusId, ${sq}Connecting${sq});
|
||||||
|
log(outputId, ${sq}Fetching ${sq} + url + ${sq}...${sq}, ${sq}system${sq});
|
||||||
|
|
||||||
|
try {
|
||||||
|
var response = await fetch(url);
|
||||||
|
var reader = response.body.getReader();
|
||||||
|
var decoder = new TextDecoder();
|
||||||
|
|
||||||
|
setStatus(statusId, ${sq}Connected${sq});
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
var result = await reader.read();
|
||||||
|
if (result.done) break;
|
||||||
|
var text = decoder.decode(result.value, {stream: true});
|
||||||
|
log(outputId, text, ${sq}text${sq});
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(statusId, ${sq}Disconnected${sq});
|
||||||
|
log(outputId, ${sq}Stream complete${sq}, ${sq}system${sq});
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(statusId, ${sq}Disconnected${sq});
|
||||||
|
log(outputId, ${sq}Error: ${sq} + err.message, ${sq}error${sq});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>'
|
||||||
|
}
|
||||||
+119
@@ -521,6 +521,13 @@ fn parse_http_method_fast(method string) http.Method {
|
|||||||
|
|
||||||
// 发送 picoev 响应 - 优化版
|
// 发送 picoev 响应 - 优化版
|
||||||
fn send_picoev_response(mut res picohttpparser.Response, ctx Context, response http.Response, keepalive bool, config PicoevConfig) {
|
fn send_picoev_response(mut res picohttpparser.Response, ctx Context, response http.Response, keepalive bool, config PicoevConfig) {
|
||||||
|
// Check if this is a streaming response
|
||||||
|
if is_streaming_response(ctx) {
|
||||||
|
// Handle streaming response
|
||||||
|
handle_picoev_streaming_response(mut res, ctx, config)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
status_code := if ctx.status_code != 0 { ctx.status_code } else { response.status_code }
|
status_code := if ctx.status_code != 0 { ctx.status_code } else { response.status_code }
|
||||||
|
|
||||||
if status_code == 200 {
|
if status_code == 200 {
|
||||||
@@ -570,6 +577,118 @@ fn send_picoev_response(mut res picohttpparser.Response, ctx Context, response h
|
|||||||
res.end()
|
res.end()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Streaming Response Handling for Picoev
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// handle_picoev_streaming_response - Handle a streaming response using PicoevStreamWriter
|
||||||
|
// This function:
|
||||||
|
// 1. Sends the HTTP headers for streaming directly to socket fd
|
||||||
|
// 2. Creates a PicoevStreamWriter with the socket fd
|
||||||
|
// 3. Executes the streaming callback
|
||||||
|
// 4. Handles errors and cleanup
|
||||||
|
fn handle_picoev_streaming_response(mut res picohttpparser.Response, ctx Context, config PicoevConfig) {
|
||||||
|
// Get stream configuration
|
||||||
|
stream_config := get_stream_config(ctx) or {
|
||||||
|
// Fallback to error response if stream config not found
|
||||||
|
res.raw('HTTP/1.1 500 Internal Server Error\r\n')
|
||||||
|
res.header('Content-Type', 'text/plain')
|
||||||
|
res.body('Stream configuration not found')
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine status code
|
||||||
|
status_code := if ctx.status_code != 0 { ctx.status_code } else { 200 }
|
||||||
|
|
||||||
|
// Build HTTP headers string for direct socket write
|
||||||
|
mut headers_str := 'HTTP/1.1 ${status_code} ${get_status_text(status_code)}\r\n'
|
||||||
|
|
||||||
|
// Add headers from context
|
||||||
|
mut has_transfer_encoding := false
|
||||||
|
mut has_connection := false
|
||||||
|
|
||||||
|
for key, value in ctx.headers {
|
||||||
|
key_len := key.len
|
||||||
|
// Skip Content-Length for streaming (we use chunked encoding)
|
||||||
|
if key_len == 14 && eq_ignore_case(key, 'content-length') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
headers_str += '${key}: ${value}\r\n'
|
||||||
|
if key_len == 17 && eq_ignore_case(key, 'transfer-encoding') {
|
||||||
|
has_transfer_encoding = true
|
||||||
|
} else if key_len == 10 && eq_ignore_case(key, 'connection') {
|
||||||
|
has_connection = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure Transfer-Encoding: chunked is set
|
||||||
|
if !has_transfer_encoding {
|
||||||
|
headers_str += 'Transfer-Encoding: chunked\r\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure Connection header is set for streaming
|
||||||
|
if !has_connection {
|
||||||
|
headers_str += 'Connection: keep-alive\r\n'
|
||||||
|
headers_str += 'Keep-Alive: timeout=${config.timeout_secs}\r\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
// End headers section
|
||||||
|
headers_str += '\r\n'
|
||||||
|
|
||||||
|
// Write headers directly to socket fd for immediate delivery
|
||||||
|
fd := res.fd
|
||||||
|
write_to_socket_fd(fd, headers_str.bytes()) or {
|
||||||
|
eprintln('[Picoev Stream Error] Failed to write headers: ${err.msg()}')
|
||||||
|
cleanup_stream_config(ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create PicoevStreamWriter for streaming (uses fd directly)
|
||||||
|
mut writer := PicoevStreamWriter.new(res)
|
||||||
|
|
||||||
|
// Create StreamContext with the writer
|
||||||
|
mut stream_ctx := StreamContext.new(writer)
|
||||||
|
|
||||||
|
// Execute the streaming callback
|
||||||
|
stream_config.callback(mut stream_ctx) or {
|
||||||
|
// Handle error
|
||||||
|
if error_handler := stream_config.error_handler {
|
||||||
|
error_handler(err, mut stream_ctx)
|
||||||
|
} else {
|
||||||
|
// Default: log error to console
|
||||||
|
eprintln('[Picoev Stream Error] ${err.msg()}')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-close the stream when callback completes
|
||||||
|
stream_ctx.close()
|
||||||
|
|
||||||
|
// Cleanup the stored configuration
|
||||||
|
cleanup_stream_config(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// write_to_socket_fd - Helper function to write bytes directly to socket fd
|
||||||
|
// Used for sending HTTP headers before streaming begins
|
||||||
|
fn write_to_socket_fd(fd int, data []u8) ! {
|
||||||
|
if data.len == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mut total_written := 0
|
||||||
|
for total_written < data.len {
|
||||||
|
remaining := data.len - total_written
|
||||||
|
ptr := unsafe { &u8(data.data) + total_written }
|
||||||
|
written := C.send(fd, ptr, remaining, 0)
|
||||||
|
if written < 0 {
|
||||||
|
return error('Failed to write to socket')
|
||||||
|
}
|
||||||
|
if written == 0 {
|
||||||
|
return error('Connection closed by peer')
|
||||||
|
}
|
||||||
|
total_written += written
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取状态码文本
|
// 获取状态码文本
|
||||||
fn get_status_text(code int) string {
|
fn get_status_text(code int) string {
|
||||||
return match code {
|
return match code {
|
||||||
|
|||||||
+982
@@ -0,0 +1,982 @@
|
|||||||
|
// streaming.v - SSE Streaming Helper for v-hono
|
||||||
|
// Provides Server-Sent Events (SSE) and streaming response support
|
||||||
|
// Reference: Hono.js Streaming Helper API
|
||||||
|
module hono
|
||||||
|
|
||||||
|
import net.http
|
||||||
|
import time
|
||||||
|
import picohttpparser
|
||||||
|
import usockets
|
||||||
|
|
||||||
|
// C function declaration for direct socket writes
|
||||||
|
fn C.send(sockfd int, buf voidptr, len int, flags int) int
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SSE Event Structure
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// SSEEvent - Server-Sent Events event structure
|
||||||
|
// Used with stream_sse() to send formatted SSE events to clients
|
||||||
|
pub struct SSEEvent {
|
||||||
|
pub:
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// StreamWriter Interface
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// StreamWriter - Abstract interface for stream writing
|
||||||
|
// Allows different server backends (picoev, uSockets) to implement their own writers
|
||||||
|
pub interface StreamWriter {
|
||||||
|
// Check if the connection is still active (immutable)
|
||||||
|
is_connected() bool
|
||||||
|
mut:
|
||||||
|
// Write raw bytes to the stream
|
||||||
|
write(data []u8) !
|
||||||
|
// Write string to the stream
|
||||||
|
write_string(data string) !
|
||||||
|
// Flush the buffer to ensure data is sent
|
||||||
|
flush() !
|
||||||
|
// Close the stream connection
|
||||||
|
close() !
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// StreamContext Structure
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// StreamContext - Context provided to streaming callbacks
|
||||||
|
// Provides methods for writing data, SSE events, and managing the stream
|
||||||
|
@[heap]
|
||||||
|
pub struct StreamContext {
|
||||||
|
mut:
|
||||||
|
writer &StreamWriter
|
||||||
|
is_closed bool
|
||||||
|
abort_fn ?fn ()
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamContext.new - Create a new StreamContext with the given writer
|
||||||
|
pub fn StreamContext.new(writer &StreamWriter) StreamContext {
|
||||||
|
return StreamContext{
|
||||||
|
writer: unsafe { writer }
|
||||||
|
is_closed: false
|
||||||
|
abort_fn: none
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// write - Write raw bytes to the stream
|
||||||
|
pub 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
|
||||||
|
pub 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)!
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeln - Write a string followed by a newline character
|
||||||
|
pub fn (mut ctx StreamContext) writeln(data string) ! {
|
||||||
|
ctx.write_string(data)!
|
||||||
|
ctx.write_string('\n')!
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
pub fn (mut ctx StreamContext) 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()!
|
||||||
|
}
|
||||||
|
|
||||||
|
// sleep - Pause execution for the specified milliseconds
|
||||||
|
pub fn (ctx StreamContext) sleep(ms int) {
|
||||||
|
time.sleep(ms * time.millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pipe - Pipe data from a byte array to the stream
|
||||||
|
pub fn (mut ctx StreamContext) pipe(data []u8) ! {
|
||||||
|
ctx.write(data)!
|
||||||
|
}
|
||||||
|
|
||||||
|
// on_abort - Register a callback to be called when the client aborts the connection
|
||||||
|
pub fn (mut ctx StreamContext) on_abort(callback fn ()) {
|
||||||
|
ctx.abort_fn = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
// close - Close the stream
|
||||||
|
pub 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
|
||||||
|
pub fn (ctx StreamContext) is_open() bool {
|
||||||
|
return !ctx.is_closed && ctx.writer.is_connected()
|
||||||
|
}
|
||||||
|
|
||||||
|
// trigger_abort - Internal method to trigger the abort callback
|
||||||
|
// Called by server backends when client disconnects
|
||||||
|
pub fn (mut ctx StreamContext) trigger_abort() {
|
||||||
|
if abort_callback := ctx.abort_fn {
|
||||||
|
abort_callback()
|
||||||
|
}
|
||||||
|
ctx.is_closed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Picoev StreamWriter Implementation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// PicoevStreamWriter - StreamWriter implementation for picoev server
|
||||||
|
// Supports chunked transfer encoding for streaming responses
|
||||||
|
// Uses direct socket fd writes for immediate data delivery (true streaming)
|
||||||
|
@[heap]
|
||||||
|
pub struct PicoevStreamWriter {
|
||||||
|
mut:
|
||||||
|
fd int // Socket file descriptor for direct writes
|
||||||
|
connected bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// PicoevStreamWriter.new - Create a new PicoevStreamWriter with the given response
|
||||||
|
// Extracts the fd from picohttpparser.Response for direct socket writes
|
||||||
|
pub fn PicoevStreamWriter.new(res &picohttpparser.Response) &PicoevStreamWriter {
|
||||||
|
return &PicoevStreamWriter{
|
||||||
|
fd: res.fd
|
||||||
|
connected: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// write - Write raw bytes to the stream using chunked transfer encoding
|
||||||
|
// Format: {hex_size}\r\n{data}\r\n
|
||||||
|
// Writes directly to socket fd for immediate delivery
|
||||||
|
pub fn (mut w PicoevStreamWriter) 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 := format_chunk_size(data.len)
|
||||||
|
// Write directly to socket fd
|
||||||
|
w.write_to_fd(chunk_header.bytes())!
|
||||||
|
w.write_to_fd(data)!
|
||||||
|
w.write_to_fd('\r\n'.bytes())!
|
||||||
|
}
|
||||||
|
|
||||||
|
// write_string - Write a string to the stream using chunked transfer encoding
|
||||||
|
pub fn (mut w PicoevStreamWriter) 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 := format_chunk_size(data.len)
|
||||||
|
// Write directly to socket fd
|
||||||
|
w.write_to_fd(chunk_header.bytes())!
|
||||||
|
w.write_to_fd(data.bytes())!
|
||||||
|
w.write_to_fd('\r\n'.bytes())!
|
||||||
|
}
|
||||||
|
|
||||||
|
// write_to_fd - Write bytes directly to the socket file descriptor
|
||||||
|
// This ensures immediate delivery without buffering
|
||||||
|
@[inline]
|
||||||
|
fn (mut w PicoevStreamWriter) write_to_fd(data []u8) ! {
|
||||||
|
if data.len == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mut total_written := 0
|
||||||
|
for total_written < data.len {
|
||||||
|
remaining := data.len - total_written
|
||||||
|
ptr := unsafe { &u8(data.data) + total_written }
|
||||||
|
written := C.send(w.fd, ptr, remaining, 0)
|
||||||
|
if written < 0 {
|
||||||
|
w.connected = false
|
||||||
|
return error('Failed to write to socket')
|
||||||
|
}
|
||||||
|
if written == 0 {
|
||||||
|
w.connected = false
|
||||||
|
return error('Connection closed by peer')
|
||||||
|
}
|
||||||
|
total_written += written
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// flush - Flush the buffer (direct fd writes are already unbuffered)
|
||||||
|
pub fn (mut w PicoevStreamWriter) flush() ! {
|
||||||
|
// Direct fd writes are unbuffered, no explicit flush needed
|
||||||
|
// But we can use TCP_NODELAY to disable Nagle's algorithm if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
// close - Close the stream by sending the final chunk
|
||||||
|
// Final chunk format: 0\r\n\r\n
|
||||||
|
pub fn (mut w PicoevStreamWriter) close() ! {
|
||||||
|
if w.connected {
|
||||||
|
// Write the final chunk (zero-length chunk) to signal end of stream
|
||||||
|
w.write_to_fd('0\r\n\r\n'.bytes()) or {}
|
||||||
|
w.connected = false
|
||||||
|
// Note: Don't close the fd here, picoev manages the connection lifecycle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// is_connected - Check if the connection is still active
|
||||||
|
pub fn (w PicoevStreamWriter) is_connected() bool {
|
||||||
|
return w.connected
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// uSockets StreamWriter Implementation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// UsocketsStreamWriter - StreamWriter implementation for uSockets server
|
||||||
|
// Supports chunked transfer encoding for streaming responses
|
||||||
|
@[heap]
|
||||||
|
pub struct UsocketsStreamWriter {
|
||||||
|
mut:
|
||||||
|
socket usockets.Socket
|
||||||
|
connected bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsocketsStreamWriter.new - Create a new UsocketsStreamWriter with the given socket
|
||||||
|
pub fn UsocketsStreamWriter.new(socket usockets.Socket) &UsocketsStreamWriter {
|
||||||
|
return &UsocketsStreamWriter{
|
||||||
|
socket: socket
|
||||||
|
connected: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// write - Write raw bytes to the stream using chunked transfer encoding
|
||||||
|
// Format: {hex_size}\r\n{data}\r\n
|
||||||
|
pub fn (mut w UsocketsStreamWriter) 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 := format_chunk_size(data.len)
|
||||||
|
w.socket.write_bytes(chunk_header)
|
||||||
|
w.socket.write_bytes(data.bytestr())
|
||||||
|
w.socket.write_bytes('\r\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// write_string - Write a string to the stream using chunked transfer encoding
|
||||||
|
pub fn (mut w UsocketsStreamWriter) 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 := format_chunk_size(data.len)
|
||||||
|
w.socket.write_bytes(chunk_header)
|
||||||
|
w.socket.write_bytes(data)
|
||||||
|
w.socket.write_bytes('\r\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// flush - Flush the buffer (uSockets handles this automatically)
|
||||||
|
pub fn (mut w UsocketsStreamWriter) flush() ! {
|
||||||
|
// uSockets automatically flushes data
|
||||||
|
// No explicit flush needed
|
||||||
|
}
|
||||||
|
|
||||||
|
// close - Close the stream by sending the final chunk
|
||||||
|
// Final chunk format: 0\r\n\r\n
|
||||||
|
pub fn (mut w UsocketsStreamWriter) close() ! {
|
||||||
|
if w.connected {
|
||||||
|
// Write the final chunk (zero-length chunk) to signal end of stream
|
||||||
|
w.socket.write_bytes('0\r\n\r\n')
|
||||||
|
w.connected = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// is_connected - Check if the connection is still active
|
||||||
|
pub fn (w UsocketsStreamWriter) is_connected() bool {
|
||||||
|
return w.connected
|
||||||
|
}
|
||||||
|
|
||||||
|
// mark_disconnected - Mark the connection as disconnected
|
||||||
|
// Called by server backends when client disconnects
|
||||||
|
pub fn (mut w UsocketsStreamWriter) mark_disconnected() {
|
||||||
|
w.connected = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Functions for Chunked Encoding
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// format_chunk_size - Format the chunk size as hex followed by CRLF
|
||||||
|
// This is used for HTTP chunked transfer encoding
|
||||||
|
@[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
|
||||||
|
// Returns the full chunk in chunked transfer encoding format
|
||||||
|
pub 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
|
||||||
|
pub 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
|
||||||
|
pub fn format_final_chunk() string {
|
||||||
|
return '0\r\n\r\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Streaming Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// StreamCallback - Callback function type for streaming
|
||||||
|
// The callback receives a mutable StreamContext and can write data to the stream
|
||||||
|
pub type StreamCallback = fn (mut StreamContext) !
|
||||||
|
|
||||||
|
// StreamErrorHandler - Error handler function type for streaming
|
||||||
|
// Called when an error occurs during streaming
|
||||||
|
pub type StreamErrorHandler = fn (err IError, mut ctx StreamContext)
|
||||||
|
|
||||||
|
// StreamResponse - Response type for streaming operations
|
||||||
|
// Contains the headers that should be sent before streaming begins
|
||||||
|
pub struct StreamResponse {
|
||||||
|
pub:
|
||||||
|
status_code int = 200
|
||||||
|
headers map[string]string
|
||||||
|
is_stream bool = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// stream - Basic binary streaming function
|
||||||
|
// Sets Transfer-Encoding: chunked and executes the callback with a StreamContext
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// writer: The StreamWriter implementation (PicoevStreamWriter or UsocketsStreamWriter)
|
||||||
|
// callback: The callback function that writes data to the stream
|
||||||
|
// error_handler: Optional error handler called if an error occurs
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// StreamResponse with the appropriate headers
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// stream(writer, fn (mut stream) ! {
|
||||||
|
// stream.write('Hello'.bytes())!
|
||||||
|
// stream.sleep(100)
|
||||||
|
// stream.write('World'.bytes())!
|
||||||
|
// })
|
||||||
|
pub fn stream(mut writer StreamWriter, 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stream_with_headers - Stream with custom initial headers
|
||||||
|
// Allows setting additional headers before streaming begins
|
||||||
|
pub fn stream_with_headers(mut writer StreamWriter, headers map[string]string, 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()
|
||||||
|
|
||||||
|
// Merge headers with required streaming headers
|
||||||
|
mut response_headers := headers.clone()
|
||||||
|
response_headers['Transfer-Encoding'] = 'chunked'
|
||||||
|
if 'Connection' !in response_headers {
|
||||||
|
response_headers['Connection'] = 'keep-alive'
|
||||||
|
}
|
||||||
|
|
||||||
|
return StreamResponse{
|
||||||
|
status_code: 200
|
||||||
|
headers: response_headers
|
||||||
|
is_stream: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// get_stream_headers - Get the headers required for basic streaming
|
||||||
|
// Returns a map of headers that should be set for chunked transfer encoding
|
||||||
|
pub fn get_stream_headers() map[string]string {
|
||||||
|
return {
|
||||||
|
'Transfer-Encoding': 'chunked'
|
||||||
|
'Connection': 'keep-alive'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// write_stream_headers - Write HTTP headers for streaming response
|
||||||
|
// This is a helper function to write the initial HTTP response line and headers
|
||||||
|
// before starting chunked data transfer
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// writer: The StreamWriter to write headers to
|
||||||
|
// status_code: HTTP status code (default 200)
|
||||||
|
// headers: Map of header name to value
|
||||||
|
pub fn write_stream_headers(mut writer StreamWriter, status_code int, headers map[string]string) ! {
|
||||||
|
// Build HTTP response line
|
||||||
|
status_text := get_status_text(status_code)
|
||||||
|
mut response := 'HTTP/1.1 ${status_code} ${status_text}\r\n'
|
||||||
|
|
||||||
|
// Add headers
|
||||||
|
for key, value in headers {
|
||||||
|
response += '${key}: ${value}\r\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add required streaming headers if not present
|
||||||
|
if 'Transfer-Encoding' !in headers {
|
||||||
|
response += 'Transfer-Encoding: chunked\r\n'
|
||||||
|
}
|
||||||
|
if 'Connection' !in headers {
|
||||||
|
response += 'Connection: keep-alive\r\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
// End headers section
|
||||||
|
response += '\r\n'
|
||||||
|
|
||||||
|
// Write headers directly (not chunked)
|
||||||
|
// Note: Headers are written raw, not using chunked encoding
|
||||||
|
// The chunked encoding starts after the headers
|
||||||
|
writer.write_string(response)!
|
||||||
|
}
|
||||||
|
|
||||||
|
// stream_text - Text streaming function
|
||||||
|
// Sets Content-Type: text/plain; charset=utf-8, Transfer-Encoding: chunked,
|
||||||
|
// and X-Content-Type-Options: nosniff headers, then executes the callback
|
||||||
|
// with a StreamContext for text streaming.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// writer: The StreamWriter implementation (PicoevStreamWriter or UsocketsStreamWriter)
|
||||||
|
// callback: The callback function that writes text data to the stream
|
||||||
|
// error_handler: Optional error handler called if an error occurs
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// StreamResponse with the appropriate headers for text streaming
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// stream_text(writer, fn (mut stream) ! {
|
||||||
|
// stream.write_string('Hello ')!
|
||||||
|
// stream.sleep(100)
|
||||||
|
// stream.writeln('World')!
|
||||||
|
// })
|
||||||
|
pub fn stream_text(mut writer StreamWriter, 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 StreamText Error] ${err.msg()}')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-close the stream when callback completes
|
||||||
|
ctx.close()
|
||||||
|
|
||||||
|
// Return response headers for text streaming
|
||||||
|
return StreamResponse{
|
||||||
|
status_code: 200
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain; charset=utf-8'
|
||||||
|
'Transfer-Encoding': 'chunked'
|
||||||
|
'X-Content-Type-Options': 'nosniff'
|
||||||
|
'Connection': 'keep-alive'
|
||||||
|
}
|
||||||
|
is_stream: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// get_stream_text_headers - Get the headers required for text streaming
|
||||||
|
// Returns a map of headers that should be set for text streaming with chunked transfer encoding
|
||||||
|
pub 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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stream_sse - Server-Sent Events streaming function
|
||||||
|
// Sets Content-Type: text/event-stream, Cache-Control: no-cache,
|
||||||
|
// and Connection: keep-alive headers, then executes the callback
|
||||||
|
// with a StreamContext for SSE streaming.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// writer: The StreamWriter implementation (PicoevStreamWriter or UsocketsStreamWriter)
|
||||||
|
// callback: The callback function that writes SSE events to the stream
|
||||||
|
// error_handler: Optional error handler called if an error occurs
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// StreamResponse with the appropriate headers for SSE streaming
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// stream_sse(writer, fn (mut stream) ! {
|
||||||
|
// stream.write_sse(SSEEvent{
|
||||||
|
// data: 'Hello World'
|
||||||
|
// event: 'message'
|
||||||
|
// id: '1'
|
||||||
|
// })!
|
||||||
|
// stream.sleep(1000)
|
||||||
|
// stream.write_sse(SSEEvent{
|
||||||
|
// data: 'Another message'
|
||||||
|
// event: 'update'
|
||||||
|
// id: '2'
|
||||||
|
// })!
|
||||||
|
// })
|
||||||
|
pub fn stream_sse(mut writer StreamWriter, 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 StreamSSE Error] ${err.msg()}')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-close the stream when callback completes
|
||||||
|
ctx.close()
|
||||||
|
|
||||||
|
// Return response headers for SSE streaming
|
||||||
|
// Requirements 3.1, 3.2, 3.3:
|
||||||
|
// - Content-Type: text/event-stream
|
||||||
|
// - Cache-Control: no-cache
|
||||||
|
// - Connection: keep-alive
|
||||||
|
return StreamResponse{
|
||||||
|
status_code: 200
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream'
|
||||||
|
'Cache-Control': 'no-cache'
|
||||||
|
'Connection': 'keep-alive'
|
||||||
|
'Transfer-Encoding': 'chunked'
|
||||||
|
}
|
||||||
|
is_stream: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// get_stream_sse_headers - Get the headers required for SSE streaming
|
||||||
|
// Returns a map of headers that should be set for Server-Sent Events streaming
|
||||||
|
pub fn get_stream_sse_headers() map[string]string {
|
||||||
|
return {
|
||||||
|
'Content-Type': 'text/event-stream'
|
||||||
|
'Cache-Control': 'no-cache'
|
||||||
|
'Connection': 'keep-alive'
|
||||||
|
'Transfer-Encoding': 'chunked'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Context-Based Streaming Functions
|
||||||
|
// ============================================================================
|
||||||
|
// These functions are designed to be used in route handlers and integrate
|
||||||
|
// with the picoev and uSockets server backends.
|
||||||
|
|
||||||
|
// StreamType - Type of streaming response
|
||||||
|
pub enum StreamType {
|
||||||
|
basic // Basic binary streaming
|
||||||
|
text // Text streaming with text/plain content type
|
||||||
|
sse // Server-Sent Events streaming
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamConfig - Configuration stored in Context for streaming responses
|
||||||
|
// This is used internally by the server backends to detect and handle streaming
|
||||||
|
pub struct StreamConfig {
|
||||||
|
pub:
|
||||||
|
stream_type StreamType
|
||||||
|
callback StreamCallback = unsafe { nil }
|
||||||
|
error_handler ?StreamErrorHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global storage for stream callbacks (workaround for V's limitations with storing functions in maps)
|
||||||
|
// Key is a unique identifier stored in Context.store['_stream_id']
|
||||||
|
// Using shared map for thread-safe access
|
||||||
|
struct StreamCallbackStorage {
|
||||||
|
mut:
|
||||||
|
callbacks shared map[string]StreamConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module-level storage instance
|
||||||
|
const stream_storage = &StreamCallbackStorage{}
|
||||||
|
|
||||||
|
// store_stream_config - Store a stream configuration
|
||||||
|
fn store_stream_config(id string, config StreamConfig) {
|
||||||
|
lock stream_storage.callbacks {
|
||||||
|
stream_storage.callbacks[id] = config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// retrieve_stream_config - Retrieve a stream configuration by ID
|
||||||
|
fn retrieve_stream_config(id string) ?StreamConfig {
|
||||||
|
rlock stream_storage.callbacks {
|
||||||
|
if id in stream_storage.callbacks {
|
||||||
|
return stream_storage.callbacks[id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return none
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove_stream_config - Remove a stream configuration by ID
|
||||||
|
fn remove_stream_config(id string) {
|
||||||
|
lock stream_storage.callbacks {
|
||||||
|
stream_storage.callbacks.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generate_stream_id - Generate a unique stream ID
|
||||||
|
fn generate_stream_id() string {
|
||||||
|
return '${time.now().unix_milli()}_${rand_int()}'
|
||||||
|
}
|
||||||
|
|
||||||
|
// rand_int - Simple random integer generator
|
||||||
|
fn rand_int() int {
|
||||||
|
return int(time.now().unix_nano() % 1000000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// c_stream - Basic binary streaming function for use in route handlers
|
||||||
|
// Sets Transfer-Encoding: chunked and stores the callback for execution by the server backend
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// c: The request Context
|
||||||
|
// callback: The callback function that writes data to the stream
|
||||||
|
// error_handler: Optional error handler called if an error occurs
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// http.Response with streaming marker headers
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// app.get('/stream', fn (mut c hono.Context) http.Response {
|
||||||
|
// return hono.c_stream(mut c, fn (mut stream hono.StreamContext) ! {
|
||||||
|
// stream.write('Hello'.bytes())!
|
||||||
|
// stream.sleep(100)
|
||||||
|
// stream.write('World'.bytes())!
|
||||||
|
// })
|
||||||
|
// })
|
||||||
|
pub fn c_stream(mut c Context, callback StreamCallback, error_handler ...StreamErrorHandler) http.Response {
|
||||||
|
// Generate unique stream ID
|
||||||
|
stream_id := generate_stream_id()
|
||||||
|
|
||||||
|
// Store stream configuration
|
||||||
|
config := StreamConfig{
|
||||||
|
stream_type: .basic
|
||||||
|
callback: callback
|
||||||
|
error_handler: if error_handler.len > 0 { error_handler[0] } else { none }
|
||||||
|
}
|
||||||
|
store_stream_config(stream_id, config)
|
||||||
|
|
||||||
|
// Mark context as streaming
|
||||||
|
c.store['_stream'] = 'true'
|
||||||
|
c.store['_stream_id'] = stream_id
|
||||||
|
c.store['_stream_type'] = 'basic'
|
||||||
|
|
||||||
|
// Set streaming headers
|
||||||
|
c.headers['Transfer-Encoding'] = 'chunked'
|
||||||
|
c.headers['Connection'] = 'keep-alive'
|
||||||
|
|
||||||
|
// Return a marker response (body will be ignored, streaming will be handled by server)
|
||||||
|
mut headers := http.new_header()
|
||||||
|
headers.add_custom('Transfer-Encoding', 'chunked') or {}
|
||||||
|
headers.add_custom('Connection', 'keep-alive') or {}
|
||||||
|
|
||||||
|
return http.Response{
|
||||||
|
status_code: 200
|
||||||
|
header: headers
|
||||||
|
body: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// c_stream_text - Text streaming function for use in route handlers
|
||||||
|
// Sets Content-Type: text/plain; charset=utf-8, Transfer-Encoding: chunked,
|
||||||
|
// and X-Content-Type-Options: nosniff headers
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// c: The request Context
|
||||||
|
// callback: The callback function that writes text data to the stream
|
||||||
|
// error_handler: Optional error handler called if an error occurs
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// http.Response with streaming marker headers
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// app.get('/stream-text', fn (mut c hono.Context) http.Response {
|
||||||
|
// return hono.c_stream_text(mut c, fn (mut stream hono.StreamContext) ! {
|
||||||
|
// stream.write_string('Hello ')!
|
||||||
|
// stream.sleep(100)
|
||||||
|
// stream.writeln('World')!
|
||||||
|
// })
|
||||||
|
// })
|
||||||
|
pub fn c_stream_text(mut c Context, callback StreamCallback, error_handler ...StreamErrorHandler) http.Response {
|
||||||
|
// Generate unique stream ID
|
||||||
|
stream_id := generate_stream_id()
|
||||||
|
|
||||||
|
// Store stream configuration
|
||||||
|
config := StreamConfig{
|
||||||
|
stream_type: .text
|
||||||
|
callback: callback
|
||||||
|
error_handler: if error_handler.len > 0 { error_handler[0] } else { none }
|
||||||
|
}
|
||||||
|
store_stream_config(stream_id, config)
|
||||||
|
|
||||||
|
// Mark context as streaming
|
||||||
|
c.store['_stream'] = 'true'
|
||||||
|
c.store['_stream_id'] = stream_id
|
||||||
|
c.store['_stream_type'] = 'text'
|
||||||
|
|
||||||
|
// Set streaming headers
|
||||||
|
c.headers['Content-Type'] = 'text/plain; charset=utf-8'
|
||||||
|
c.headers['Transfer-Encoding'] = 'chunked'
|
||||||
|
c.headers['X-Content-Type-Options'] = 'nosniff'
|
||||||
|
c.headers['Connection'] = 'keep-alive'
|
||||||
|
|
||||||
|
// Return a marker response
|
||||||
|
mut headers := http.new_header()
|
||||||
|
headers.add_custom('Content-Type', 'text/plain; charset=utf-8') or {}
|
||||||
|
headers.add_custom('Transfer-Encoding', 'chunked') or {}
|
||||||
|
headers.add_custom('X-Content-Type-Options', 'nosniff') or {}
|
||||||
|
headers.add_custom('Connection', 'keep-alive') or {}
|
||||||
|
|
||||||
|
return http.Response{
|
||||||
|
status_code: 200
|
||||||
|
header: headers
|
||||||
|
body: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// c_stream_sse - Server-Sent Events streaming function for use in route handlers
|
||||||
|
// Sets Content-Type: text/event-stream, Cache-Control: no-cache,
|
||||||
|
// and Connection: keep-alive headers
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// c: The request Context
|
||||||
|
// callback: The callback function that writes SSE events to the stream
|
||||||
|
// error_handler: Optional error handler called if an error occurs
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// http.Response with streaming marker headers
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// app.get('/sse', fn (mut c hono.Context) http.Response {
|
||||||
|
// return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
|
||||||
|
// stream.write_sse(hono.SSEEvent{
|
||||||
|
// data: 'Hello World'
|
||||||
|
// event: 'message'
|
||||||
|
// id: '1'
|
||||||
|
// })!
|
||||||
|
// stream.sleep(1000)
|
||||||
|
// stream.write_sse(hono.SSEEvent{
|
||||||
|
// data: 'Another message'
|
||||||
|
// event: 'update'
|
||||||
|
// id: '2'
|
||||||
|
// })!
|
||||||
|
// })
|
||||||
|
// })
|
||||||
|
pub fn c_stream_sse(mut c Context, callback StreamCallback, error_handler ...StreamErrorHandler) http.Response {
|
||||||
|
// Generate unique stream ID
|
||||||
|
stream_id := generate_stream_id()
|
||||||
|
|
||||||
|
// Store stream configuration
|
||||||
|
config := StreamConfig{
|
||||||
|
stream_type: .sse
|
||||||
|
callback: callback
|
||||||
|
error_handler: if error_handler.len > 0 { error_handler[0] } else { none }
|
||||||
|
}
|
||||||
|
store_stream_config(stream_id, config)
|
||||||
|
|
||||||
|
// Mark context as streaming
|
||||||
|
c.store['_stream'] = 'true'
|
||||||
|
c.store['_stream_id'] = stream_id
|
||||||
|
c.store['_stream_type'] = 'sse'
|
||||||
|
|
||||||
|
// Set SSE headers (Requirements 3.1, 3.2, 3.3)
|
||||||
|
c.headers['Content-Type'] = 'text/event-stream'
|
||||||
|
c.headers['Cache-Control'] = 'no-cache'
|
||||||
|
c.headers['Connection'] = 'keep-alive'
|
||||||
|
c.headers['Transfer-Encoding'] = 'chunked'
|
||||||
|
|
||||||
|
// Return a marker response
|
||||||
|
mut headers := http.new_header()
|
||||||
|
headers.add_custom('Content-Type', 'text/event-stream') or {}
|
||||||
|
headers.add_custom('Cache-Control', 'no-cache') or {}
|
||||||
|
headers.add_custom('Connection', 'keep-alive') or {}
|
||||||
|
headers.add_custom('Transfer-Encoding', 'chunked') or {}
|
||||||
|
|
||||||
|
return http.Response{
|
||||||
|
status_code: 200
|
||||||
|
header: headers
|
||||||
|
body: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// is_streaming_response - Check if a Context represents a streaming response
|
||||||
|
// Used by server backends to detect streaming responses
|
||||||
|
pub fn is_streaming_response(ctx Context) bool {
|
||||||
|
return '_stream' in ctx.store && ctx.store['_stream'] == 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
// get_stream_config - Get the stream configuration from Context
|
||||||
|
// Returns the StreamConfig if this is a streaming response, none otherwise
|
||||||
|
pub fn get_stream_config(ctx Context) ?StreamConfig {
|
||||||
|
if !is_streaming_response(ctx) {
|
||||||
|
return none
|
||||||
|
}
|
||||||
|
|
||||||
|
stream_id := ctx.store['_stream_id'] or { return none }
|
||||||
|
return retrieve_stream_config(stream_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanup_stream_config - Remove stream configuration after use
|
||||||
|
// Should be called by server backends after streaming is complete
|
||||||
|
pub fn cleanup_stream_config(ctx Context) {
|
||||||
|
if stream_id := ctx.store['_stream_id'] {
|
||||||
|
remove_stream_config(stream_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// execute_stream - Execute a streaming response with the given writer
|
||||||
|
// This is the main entry point for server backends to handle streaming
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// ctx: The request Context with streaming configuration
|
||||||
|
// writer: The StreamWriter implementation for the server backend
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// true if streaming was executed, false if not a streaming response
|
||||||
|
pub fn execute_stream(ctx Context, mut writer StreamWriter) bool {
|
||||||
|
config := get_stream_config(ctx) or { return false }
|
||||||
|
|
||||||
|
// Create StreamContext with the writer
|
||||||
|
mut stream_ctx := StreamContext.new(writer)
|
||||||
|
|
||||||
|
// Execute the callback and handle errors
|
||||||
|
config.callback(mut stream_ctx) or {
|
||||||
|
// Handle error
|
||||||
|
if error_handler := config.error_handler {
|
||||||
|
error_handler(err, mut stream_ctx)
|
||||||
|
} else {
|
||||||
|
// Default: log error to console
|
||||||
|
eprintln('[SSE Stream Error] ${err.msg()}')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-close the stream when callback completes
|
||||||
|
stream_ctx.close()
|
||||||
|
|
||||||
|
// Cleanup the stored configuration
|
||||||
|
cleanup_stream_config(ctx)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+6
-6
@@ -7,8 +7,8 @@ $if windows {
|
|||||||
#flag -DLIBUS_USE_LIBUV
|
#flag -DLIBUS_USE_LIBUV
|
||||||
#flag -DLIBUS_NO_SSL
|
#flag -DLIBUS_NO_SSL
|
||||||
#flag -I@VMODROOT/usockets/include
|
#flag -I@VMODROOT/usockets/include
|
||||||
#flag -L@VMODROOT/lib
|
#flag -L@VMODROOT/usockets/lib
|
||||||
#flag @VMODROOT/lib/libusockets_full.a
|
#flag @VMODROOT/usockets/lib/libusockets_full.a
|
||||||
#flag -lws2_32 -liphlpapi -lpsapi -luserenv -lole32
|
#flag -lws2_32 -liphlpapi -lpsapi -luserenv -lole32
|
||||||
} $else $if macos {
|
} $else $if macos {
|
||||||
// macOS: 支持 Apple Silicon (M1/M2/M3) 和 Intel Mac
|
// macOS: 支持 Apple Silicon (M1/M2/M3) 和 Intel Mac
|
||||||
@@ -26,16 +26,16 @@ $if windows {
|
|||||||
|
|
||||||
// uSockets 库路径
|
// uSockets 库路径
|
||||||
#flag -I@VMODROOT/usockets/include
|
#flag -I@VMODROOT/usockets/include
|
||||||
#flag -L@VMODROOT/lib
|
#flag -L@VMODROOT/usockets/lib
|
||||||
#flag @VMODROOT/lib/libusockets_full.a
|
#flag @VMODROOT/usockets/lib/libusockets_full.a
|
||||||
#flag -luv
|
#flag -luv
|
||||||
} $else {
|
} $else {
|
||||||
// Linux 和其他平台
|
// Linux 和其他平台
|
||||||
#flag -DLIBUS_USE_LIBUV
|
#flag -DLIBUS_USE_LIBUV
|
||||||
#flag -DLIBUS_NO_SSL
|
#flag -DLIBUS_NO_SSL
|
||||||
#flag -I@VMODROOT/usockets/include
|
#flag -I@VMODROOT/usockets/include
|
||||||
#flag -L@VMODROOT/lib
|
#flag -L@VMODROOT/usockets/lib
|
||||||
#flag @VMODROOT/lib/libusockets_full.a
|
#flag @VMODROOT/usockets/lib/libusockets_full.a
|
||||||
#flag -luv
|
#flag -luv
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -577,6 +577,13 @@ fn exec_middlewares_usockets(idx int, middlewares []ContextMiddleware, mut ctx C
|
|||||||
|
|
||||||
// 发送 uSockets 响应 - 优化版
|
// 发送 uSockets 响应 - 优化版
|
||||||
fn send_usockets_response(s usockets.Socket, ctx Context, response http.Response) {
|
fn send_usockets_response(s usockets.Socket, ctx Context, response http.Response) {
|
||||||
|
// Check if this is a streaming response
|
||||||
|
if is_streaming_response(ctx) {
|
||||||
|
// Handle streaming response
|
||||||
|
handle_usockets_streaming_response(s, ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
status_code := if ctx.status_code != 0 { ctx.status_code } else { response.status_code }
|
status_code := if ctx.status_code != 0 { ctx.status_code } else { response.status_code }
|
||||||
|
|
||||||
mut resp := strings.new_builder(512)
|
mut resp := strings.new_builder(512)
|
||||||
@@ -633,6 +640,100 @@ fn send_usockets_response(s usockets.Socket, ctx Context, response http.Response
|
|||||||
s.write_bytes(resp.str())
|
s.write_bytes(resp.str())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Streaming Response Handling for uSockets
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// handle_usockets_streaming_response - Handle a streaming response using UsocketsStreamWriter
|
||||||
|
// This function:
|
||||||
|
// 1. Sends the HTTP headers for streaming
|
||||||
|
// 2. Creates a UsocketsStreamWriter
|
||||||
|
// 3. Executes the streaming callback
|
||||||
|
// 4. Handles errors and cleanup
|
||||||
|
//
|
||||||
|
// Requirements: 7.1, 7.2, 7.3, 7.4
|
||||||
|
fn handle_usockets_streaming_response(s usockets.Socket, ctx Context) {
|
||||||
|
// Get stream configuration
|
||||||
|
stream_config := get_stream_config(ctx) or {
|
||||||
|
// Fallback to error response if stream config not found
|
||||||
|
s.write_bytes('HTTP/1.1 500 Internal Server Error\r\nContent-Type: text/plain\r\nContent-Length: 32\r\n\r\nStream configuration not found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine status code
|
||||||
|
status_code := if ctx.status_code != 0 { ctx.status_code } else { 200 }
|
||||||
|
|
||||||
|
// Build HTTP response headers
|
||||||
|
mut resp := strings.new_builder(512)
|
||||||
|
|
||||||
|
// Status line
|
||||||
|
resp.write_string('HTTP/1.1 ')
|
||||||
|
resp.write_string(status_code.str())
|
||||||
|
resp.write_string(' ')
|
||||||
|
resp.write_string(get_status_text_usockets(status_code))
|
||||||
|
resp.write_string('\r\n')
|
||||||
|
|
||||||
|
// Send headers from context
|
||||||
|
mut has_transfer_encoding := false
|
||||||
|
mut has_connection := false
|
||||||
|
|
||||||
|
for key, value in ctx.headers {
|
||||||
|
key_len := key.len
|
||||||
|
// Skip Content-Length for streaming (we use chunked encoding)
|
||||||
|
if key_len == 14 && eq_ignore_case_usockets(key, 'content-length') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resp.write_string(key)
|
||||||
|
resp.write_string(': ')
|
||||||
|
resp.write_string(value)
|
||||||
|
resp.write_string('\r\n')
|
||||||
|
if key_len == 17 && eq_ignore_case_usockets(key, 'transfer-encoding') {
|
||||||
|
has_transfer_encoding = true
|
||||||
|
} else if key_len == 10 && eq_ignore_case_usockets(key, 'connection') {
|
||||||
|
has_connection = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure Transfer-Encoding: chunked is set (Requirement 7.2)
|
||||||
|
if !has_transfer_encoding {
|
||||||
|
resp.write_string('Transfer-Encoding: chunked\r\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure Connection header is set for streaming
|
||||||
|
if !has_connection {
|
||||||
|
resp.write_string('Connection: keep-alive\r\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// End headers section
|
||||||
|
resp.write_string('\r\n')
|
||||||
|
|
||||||
|
// Send headers to client
|
||||||
|
s.write_bytes(resp.str())
|
||||||
|
|
||||||
|
// Create UsocketsStreamWriter for streaming (Requirement 7.1)
|
||||||
|
mut writer := UsocketsStreamWriter.new(s)
|
||||||
|
|
||||||
|
// Create StreamContext with the writer
|
||||||
|
mut stream_ctx := StreamContext.new(writer)
|
||||||
|
|
||||||
|
// Execute the streaming callback (Requirement 7.4 - compatibility with callback architecture)
|
||||||
|
stream_config.callback(mut stream_ctx) or {
|
||||||
|
// Handle error (Requirement 7.3 - handle connection close events)
|
||||||
|
if error_handler := stream_config.error_handler {
|
||||||
|
error_handler(err, mut stream_ctx)
|
||||||
|
} else {
|
||||||
|
// Default: log error to console
|
||||||
|
eprintln('[uSockets Stream Error] ${err.msg()}')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-close the stream when callback completes
|
||||||
|
stream_ctx.close()
|
||||||
|
|
||||||
|
// Cleanup the stored configuration
|
||||||
|
cleanup_stream_config(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
// 大小写不敏感字符串比较(避免分配)
|
// 大小写不敏感字符串比较(避免分配)
|
||||||
@[inline]
|
@[inline]
|
||||||
fn eq_ignore_case_usockets(a string, b string) bool {
|
fn eq_ignore_case_usockets(a string, b string) bool {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
Module {
|
Module {
|
||||||
name: 'hono'
|
name: 'hono'
|
||||||
description: 'A high-performance V language web framework inspired by Hono.js, featuring hybrid routing, LRU cache, middleware support, and more.'
|
description: 'A high-performance V language web framework inspired by Hono.js, featuring hybrid routing, LRU cache, middleware support, and more.'
|
||||||
version: '0.6.0'
|
version: '0.7.2'
|
||||||
license: 'MIT'
|
license: 'MIT'
|
||||||
dependencies: []
|
dependencies: []
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user