Files
zyt/server/tests/AssistantSseHeartbeatTest.php
2026-10-27 14:03:35 +08:00

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";
}