92 lines
4.9 KiB
PHP
92 lines
4.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace app\adminapi\service {
|
|
function hrtime(bool $asNumber = false): int { return $GLOBALS['heartbeatNowNs']; }
|
|
}
|
|
|
|
namespace app\adminapi\controller\tcm {
|
|
// Keep the real emitter inside a test-owned buffer, without changing PHP's
|
|
// process-wide headers, output buffers or disconnect handling.
|
|
function ob_get_level(): int { return 0; }
|
|
function ini_set(string $name, string $value) { return false; }
|
|
function ignore_user_abort(bool $enable): int { return 0; }
|
|
function header(string $value): void { $GLOBALS['heartbeatHeaders'][] = $value; }
|
|
function ob_flush(): void {}
|
|
function flush(): void {}
|
|
function connection_aborted(): int { return $GLOBALS['heartbeatDisconnected'] ? 1 : 0; }
|
|
}
|
|
|
|
namespace {
|
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
|
|
|
use app\adminapi\controller\tcm\DiagnosisController;
|
|
use app\adminapi\service\AssistantSseProtocol;
|
|
|
|
$heartbeatNowNs = 1_000_000_000;
|
|
$heartbeatDisconnected = false;
|
|
$heartbeatHeaders = [];
|
|
$checks = 0;
|
|
function heartbeatExpect(bool $ok, string $message): void {
|
|
++$GLOBALS['checks'];
|
|
if (!$ok) throw new RuntimeException($message);
|
|
}
|
|
function heartbeatCapture(callable $action): array {
|
|
ob_start();
|
|
try { $accepted = $action(); return [$accepted, (string) ob_get_contents()]; }
|
|
finally { ob_end_clean(); }
|
|
}
|
|
|
|
$protocol = new AssistantSseProtocol();
|
|
heartbeatExpect($protocol->heartbeat() === null, 'no heartbeat precedes authenticated start');
|
|
heartbeatExpect(str_contains((string) $protocol->encode('start', []), '"seq":1'), 'start owns the first sequence');
|
|
$heartbeatNowNs += 9_999_999_999;
|
|
heartbeatExpect($protocol->heartbeat() === null, 'idle heartbeat is throttled below ten seconds');
|
|
++$heartbeatNowNs;
|
|
heartbeatExpect($protocol->heartbeat() === ": keep-alive\n\n", 'ten idle seconds emit only an SSE comment');
|
|
heartbeatExpect($protocol->heartbeat() === null, 'poll bursts do not flood the stream');
|
|
$heartbeatNowNs += 9_000_000_000;
|
|
heartbeatExpect(str_contains((string) $protocol->encode('delta', ['text' => 'synthetic']), '"seq":2'),
|
|
'heartbeat does not increment the application sequence or invent a delta');
|
|
$heartbeatNowNs += 9_000_000_000;
|
|
heartbeatExpect($protocol->heartbeat() === null, 'real output resets the idle interval');
|
|
$heartbeatNowNs += 1_000_000_000;
|
|
heartbeatExpect($protocol->heartbeat() !== null, 'heartbeat resumes after real output becomes idle');
|
|
heartbeatExpect(str_contains((string) $protocol->encode('done', []), '"seq":3'), 'done retains its original sequence');
|
|
$heartbeatNowNs += 100_000_000_000;
|
|
heartbeatExpect($protocol->heartbeat() === null, 'no heartbeat follows successful terminal');
|
|
$failed = new AssistantSseProtocol();
|
|
$failed->encode('start', []);
|
|
$failed->encode('error', ['code' => 'UPSTREAM_TIMEOUT']);
|
|
$heartbeatNowNs += 100_000_000_000;
|
|
heartbeatExpect($failed->heartbeat() === null, 'no heartbeat follows timeout or cancellation terminal');
|
|
|
|
$reflection = new ReflectionClass(DiagnosisController::class);
|
|
$controller = $reflection->newInstanceWithoutConstructor();
|
|
[$emit, $opening] = heartbeatCapture(static fn () => $reflection->getMethod('openAssistantSse')->invoke($controller));
|
|
heartbeatExpect(is_callable($emit) && str_starts_with($opening, ':') && str_ends_with($opening, "\n\n"),
|
|
'real controller opens a flushed comment stream');
|
|
[$accepted, $wire] = heartbeatCapture(static fn () => $emit('heartbeat', []));
|
|
heartbeatExpect($accepted && $wire === '', 'suppressed heartbeat is not interpreted as a disconnected client');
|
|
heartbeatCapture(static fn () => $emit('start', []));
|
|
for ($second = 1; $second <= 90; ++$second) {
|
|
$heartbeatNowNs += 1_000_000_000;
|
|
[$accepted, $wire] = heartbeatCapture(static fn () => $emit('heartbeat', []));
|
|
heartbeatExpect($accepted && $wire === ($second % 10 === 0 ? ": keep-alive\n\n" : ''),
|
|
'silent upstream keeps the connection alive without progress or clinical data');
|
|
}
|
|
[$accepted, $wire] = heartbeatCapture(static fn () => $emit('delta', ['text' => 'synthetic']));
|
|
heartbeatExpect($accepted && str_contains($wire, '"seq":2'), 'ninety seconds of waiting do not consume event numbers');
|
|
$heartbeatDisconnected = true;
|
|
$heartbeatNowNs += 10_000_000_000;
|
|
[$accepted, $wire] = heartbeatCapture(static fn () => $emit('heartbeat', []));
|
|
heartbeatExpect(!$accepted && $wire === '', 'disconnected transport receives no more output');
|
|
$heartbeatDisconnected = false;
|
|
heartbeatCapture(static fn () => $emit('error', ['code' => 'UPSTREAM_TIMEOUT']));
|
|
[$accepted, $wire] = heartbeatCapture(static fn () => $emit('heartbeat', []));
|
|
heartbeatExpect(!$accepted && $wire === '', 'real emitter gates heartbeat on its shared terminal state');
|
|
|
|
echo "Assistant SSE idle heartbeat: {$checks} checks OK\n";
|
|
}
|