Gliner/Testing/GlinerServiceTests.cs
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using GlinerPoc.Preprocessing;
using Sandbox;
namespace GlinerPoc.Neural;
/// <summary>
/// Phase 7 lifecycle test driver (7.32–7.36, 7.39): drives
/// GlinerDecisionService directly through the required lifecycle scenarios
/// and logs deterministic PASS/FAIL results. Add to the workbench scene and
/// set RunOnStart, or trigger manually. UI-independent.
/// </summary>
[Title( "GLiNER Service Tests" )]
[Category( "GLiNER" )]
public sealed class GlinerServiceTests : Component
{
[Property]
public bool RunOnStart { get; set; }
private GlinerDecisionService Service { get; set; }
private int _passed;
private int _failed;
private static GlinerClassificationRequest Req( string context = "Health: 25%. Ammo: 2/10. Enemy close.",
string task = "Choose the best action." ) =>
new( context, task, new[]
{
new GlinerCandidate( "attack" ), new GlinerCandidate( "heal" ),
new GlinerCandidate( "reload" ), new GlinerCandidate( "retreat" )
} );
protected override void OnStart()
{
Service = GetComponent<GlinerDecisionService>();
if ( RunOnStart )
{
_ = RunAllAsync();
}
}
private async Task RunAllAsync()
{
Log.Info( "[GLI:T07] service lifecycle tests start" );
await WaitForReady();
// 1. successful sequential requests (no reload between)
await TestSequential();
// 2. invalid request (no candidates)
await TestInvalid();
// 3. >256 token overflow
await TestOverflow();
// 4. early cancel
await TestEarlyCancel();
// 5. late cancel (race with completion)
await TestLateCancel();
// 6. rapid Run/Cancel/Run
await TestRapid();
// 7. stale-result suppression via generation invalidation
await TestStale();
Log.Info( $"[GLI:T07] lifecycle tests complete passed={_passed} failed={_failed}" );
Log.Info( _failed == 0 ? "[GLI:T07] ALL PASS" : $"[GLI:T07] FAILURES ({_failed})" );
}
private async Task WaitForReady()
{
int waited = 0;
while ( Service is null || !Service.IsReady )
{
await Task.Delay( 200 );
waited += 200;
if ( waited > 15000 || Service is { State: GlinerServiceState.Faulted } )
{
Gate( "wait_ready", false, $"state={Service?.State}" );
return;
}
}
Gate( "wait_ready", true, $"init_ms={Service.InitializationMs:N1}" );
}
// 7.33 — repeated requests: no reload, no stale results
private async Task TestSequential()
{
try
{
long genBefore = Service.Generation;
var r1 = await Service.ClassifyAsync( Req() );
var r2 = await Service.ClassifyAsync( Req( "Thirst: critical. A clean stream is 50 meters away." ) );
var r3 = await Service.ClassifyAsync( Req( "The sky is dark and stars are out.", "Time of day?" ) );
bool ok = r1 is not null && r2 is not null && r3 is not null
&& Service.Generation == genBefore && Service.IsReady
&& Service.ActiveRequestId >= 3;
Gate( "sequential_requests", ok,
$"r1={r1?.SelectedLabel} r2={r2?.SelectedLabel} r3={r3?.SelectedLabel} gen_stable={Service.Generation == genBefore}" );
}
catch ( Exception e )
{
Gate( "sequential_requests", false, e.Message );
}
}
// 7.15 — invalid input: no candidates
private async Task TestInvalid()
{
try
{
var before = Service.State;
await Service.ClassifyAsync( new GlinerClassificationRequest( "ctx", "task",
Array.Empty<GlinerCandidate>() ) );
Gate( "invalid_request", false, "no exception thrown" );
}
catch ( InvalidOperationException )
{
Gate( "invalid_request", Service.IsReady, "rejected cleanly; service still Ready" );
}
catch ( Exception e )
{
Gate( "invalid_request", false, $"unexpected {e.GetType().Name}" );
}
}
// 7.32 — over-256-token request
private async Task TestOverflow()
{
try
{
string big = string.Join( " ", System.Linq.Enumerable.Repeat(
"The corridor to the north is blocked by debris and debris again.", 40 ) );
await Service.ClassifyAsync( Req( big ) );
Gate( "token_overflow", false, "no error thrown" );
}
catch ( InvalidOperationException e ) when ( e.Message.Contains( "limit" ) || e.Message.Contains( "exceeds" ) )
{
Gate( "token_overflow", Service.IsReady, "clear limit error; service Ready" );
}
catch ( Exception e )
{
Gate( "token_overflow", false, $"unexpected {e.GetType().Name}: {e.Message}" );
}
}
// 7.34 — early cancel
private async Task TestEarlyCancel()
{
try
{
var run = Service.ClassifyAsync( Req() );
await Task.Delay( 150 );
var stateAtCancel = Service.State;
Service.Cancel();
var sw = System.Diagnostics.Stopwatch.StartNew();
var result = await run;
double latencyMs = sw.Elapsed.TotalMilliseconds;
bool noResult = result is null;
bool readyAgain = Service.IsReady;
var next = await Service.ClassifyAsync( Req() );
Gate( "early_cancel", noResult && readyAgain && next is not null,
$"state_at_cancel={stateAtCancel} cancel_latency={latencyMs:N0}ms next_request_ok={next is not null}" );
}
catch ( Exception e )
{
Gate( "early_cancel", false, e.Message );
}
}
// 7.35 — late cancel: deterministic single outcome
private async Task TestLateCancel()
{
try
{
var run = Service.ClassifyAsync( Req() );
// wait until inference is nearly done, then cancel
await Task.Delay( 3800 );
Service.Cancel();
var result = await run;
bool ready = Service.IsReady;
// Either completed-before-cancel (result published) or cancelled
// (null). Both are valid; state must be Ready and consistent.
Gate( "late_cancel", ready,
$"outcome={( result is null ? "cancelled" : $"completed ({result.SelectedLabel})" )} state_ready={ready}" );
}
catch ( Exception e )
{
Gate( "late_cancel", false, e.Message );
}
}
// 7.36 — rapid Run/Cancel/Run
private async Task TestRapid()
{
try
{
var run1 = Service.ClassifyAsync( Req() );
await Task.Delay( 120 );
Service.Cancel();
var r1 = await run1;
await Task.Delay( 50 );
var run2 = Service.ClassifyAsync( Req() );
await Task.Delay( 120 );
Service.Cancel();
var r2 = await run2;
var r3 = await Service.ClassifyAsync( Req() );
Gate( "rapid_run_cancel",
r1 is null && r2 is null && r3 is not null && Service.IsReady,
$"r1={( r1 is null ? "cancelled" : r1.SelectedLabel )} " +
$"r2={( r2 is null ? "cancelled" : r2.SelectedLabel )} " +
$"r3={( r3 is null ? "null" : r3.SelectedLabel )} state={Service.State}" );
}
catch ( Exception e )
{
Gate( "rapid_run_cancel", false, e.Message );
}
}
// 7.9/7.14 — stale-result suppression: generation bump discards in-flight work
private async Task TestStale()
{
try
{
var run = Service.ClassifyAsync( Req() );
await Task.Delay( 100 );
// simulate lifecycle invalidation (scene destroy / hotload behaviour):
// force the generation bump via disable/enable cycle.
Service.Enabled = false;
await Task.Delay( 30 );
Service.Enabled = true;
var result = await run;
bool discarded = result is null;
// service must re-initialize after invalidation
await WaitForReady();
var fresh = await Service.ClassifyAsync( Req() );
Gate( "stale_result", discarded && fresh is not null,
$"in_flight={( result is null ? "discarded" : "PUBLISHED (BAD)" )} reinit_ready={Service.IsReady}" );
}
catch ( Exception e )
{
Gate( "stale_result", false, e.Message );
}
}
private void Gate( string name, bool pass, string detail )
{
if ( pass )
{
_passed++;
Log.Info( $"[GLI:T07] PASS {name} {detail}" );
}
else
{
_failed++;
Log.Error( $"[GLI:T07] FAIL {name} {detail}" );
}
}
}