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