Files
City10/tests/Feature/AirportTest.php
T
marcocosta10000 46b15ec7be feat(city): add city landmarks, stores, luminous signs, and ATC echo guard
Add university and fire station civic buildings plus 7 new store types,
each with facade and luminous blade signs. Extend landmark material
colors and sign accents. Fix ATC dispatch to reject prompt-echoing AI
responses via heuristic guard. Add CityPlacesTest for collision and
roadway avoidance, and extend AirportTest and SignLabelsTest.
2026-10-31 22:22:44 -03:00

260 lines
9.8 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\AiAgent;
use App\Models\Character;
use App\Models\User;
use App\Support\Airports;
use Database\Seeders\CityWorldSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
/**
* Airports payload, control-tower agent (AI_ATC_MODEL) and the AI
* clearance dispatch used by the flight operations.
*/
class AirportTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Character $character;
/**
* Boot world fixtures.
*/
protected function setUp(): void
{
parent::setUp();
$this->seed(CityWorldSeeder::class);
$this->user = User::factory()->create(['status' => User::STATUS_ACTIVE, 'is_active' => true]);
$this->character = Character::ensureForUser($this->user);
$this->actingAs($this->user);
}
/**
* The world payload carries both airports with their core geometry.
*/
public function test_city_data_includes_two_airports(): void
{
$payload = $this->getJson('/city/data')->assertOk()->json();
$this->assertArrayHasKey('airports', $payload);
$sites = $payload['airports']['sites'];
$this->assertCount(2, $sites);
// Sites follow the compass: +Z is south (noon sun points +Z).
$this->assertSame(['sul', 'norte'], array_column($sites, 'id'));
$this->assertSame('Aeroporto City10 Sul', $sites[0]['name']);
$this->assertSame('Aeroporto Regional do Norte', $sites[1]['name']);
$this->assertSame('x', $sites[0]['runway']['axis']);
$this->assertSame('z', $sites[1]['runway']['axis']);
foreach ($sites as $site) {
$this->assertNotEmpty($site['runway']);
$this->assertNotEmpty($site['taxiways']);
$this->assertNotEmpty($site['apron']);
$this->assertNotEmpty($site['terminal']);
$this->assertNotEmpty($site['hangars']);
$this->assertNotEmpty($site['stands']);
$this->assertNotEmpty($site['helipad']);
$this->assertNotEmpty($site['tower']);
}
$this->assertEquals(Airports::VISIT_HALF_WIDTH, $payload['airports']['visit']['half_width']);
$this->assertEquals(Airports::VISIT_MAX_Z, $payload['airports']['visit']['max_z']);
}
/**
* The control tower agent exists with the configured ATC model and
* stands at the north tower.
*/
public function test_control_tower_agent_uses_atc_model(): void
{
$atc = AiAgent::query()->where('type', 'atc')->firstOrFail();
$this->assertSame('Torre de Controle City10', $atc->name);
$this->assertSame(config('ai.atc_model'), $atc->model);
$this->assertSame('poolside/laguna-xs-2.1:free', $atc->model);
$this->assertContains($atc->model, config('ai.allowed_models'));
// Tower of the north site: layoutMaxZ(160) + 118 = 278, x = 10.
$this->assertEquals(10.0, (float) $atc->pos_x);
$this->assertEquals(278.0, (float) $atc->pos_z);
}
/**
* The airport endpoint exposes sites, tower identity and clearance.
*/
public function test_airport_endpoint_exposes_atc_info(): void
{
$this->getJson('/city/airport')
->assertOk()
->assertJsonPath('atc.name', 'Torre de Controle City10')
->assertJsonPath('atc.model', 'poolside/laguna-xs-2.1:free')
->assertJsonPath('clearance', null);
$payload = $this->getJson('/city/airport')->json();
$this->assertCount(2, $payload['sites']);
}
/**
* Dispatch asks the tower model for a clearance and stores it.
*/
public function test_dispatch_generates_model_clearance(): void
{
config(['ai.openrouter.api_key' => 'test-key']);
Http::fake([
'openrouter.ai/*' => Http::response([
'choices' => [['message' => ['content' => 'C10-123, AzulCéu, autorizado decolar pista 09.']]],
]),
]);
$this->postJson('/city/airport/dispatch')
->assertStatus(202)
->assertJsonPath('accepted', true);
// The after-response job ran with the request lifecycle.
$clearance = cache()->get('airport.clearance');
$this->assertNotNull($clearance);
$this->assertSame('model', $clearance['source']);
$this->assertSame('C10-123, AzulCéu, autorizado decolar pista 09.', $clearance['text']);
Http::assertSent(function ($request): bool {
return $request['model'] === config('ai.atc_model')
&& str_contains((string) $request['messages'][0]['content'], 'torre de controle');
});
$atc = AiAgent::query()->where('type', 'atc')->firstOrFail();
$this->assertSame(0, (int) $atc->error_count);
$this->assertSame('active', $atc->status);
$decision = collect($atc->last_decision)->last();
$this->assertSame('atc', $decision['role'] ?? null);
}
/**
* Without an API key the deterministic fallback clearance is stored
* and the failure counts toward backoff.
*/
public function test_dispatch_without_key_stores_fallback(): void
{
config(['ai.openrouter.api_key' => null]);
Http::preventStrayRequests();
$this->postJson('/city/airport/dispatch')
->assertStatus(202)
->assertJsonPath('accepted', true);
$clearance = cache()->get('airport.clearance');
$this->assertNotNull($clearance);
$this->assertSame('fallback', $clearance['source']);
$this->assertStringContainsString('autorizado para decolagem', $clearance['text']);
$atc = AiAgent::query()->where('type', 'atc')->firstOrFail();
$this->assertSame(1, (int) $atc->error_count);
}
/**
* Internal cooldown: only one dispatch per 30 seconds.
*/
public function test_dispatch_has_internal_cooldown(): void
{
config(['ai.openrouter.api_key' => null]);
Http::preventStrayRequests();
$this->postJson('/city/airport/dispatch')->assertJsonPath('accepted', true);
$this->postJson('/city/airport/dispatch')
->assertStatus(202)
->assertJsonPath('accepted', false)
->assertJsonPath('reason', 'cooldown');
$this->assertNotNull(cache()->get('airport.dispatch.lock'));
}
/**
* Position sync reaches both airport grounds inside their X band.
*/
public function test_position_allows_airport_grounds(): void
{
$this->postJson('/city/actions/position', ['pos_x' => 0, 'pos_z' => 300])
->assertOk();
$this->character->refresh();
$this->assertEquals(300.0, (float) $this->character->pos_z);
$this->postJson('/city/actions/position', ['pos_x' => 0, 'pos_z' => -320])
->assertOk();
$this->character->refresh();
$this->assertEquals(-320.0, (float) $this->character->pos_z);
// Outside the airport band the old bounds still apply.
$this->postJson('/city/actions/position', ['pos_x' => 150, 'pos_z' => 300])
->assertOk();
$this->character->refresh();
$this->assertEquals((float) $this->character->city->bounds_max_z, (float) $this->character->pos_z);
}
/**
* The clearance chain walks ATC model → default → fallback model
* (AI_FALLBACK_MODEL) until one answers.
*/
public function test_dispatch_falls_back_through_model_chain(): void
{
config(['ai.openrouter.api_key' => 'test-key']);
Http::fake([
'openrouter.ai/*' => Http::sequence()
->push(['message' => 'No such model'], 404)
->push(['message' => 'Upstream error'], 500)
->push(['choices' => [['message' => ['content' => 'C10-310 autorizado, pista 09.']]]], 200),
]);
$this->postJson('/city/airport/dispatch')
->assertStatus(202)
->assertJsonPath('accepted', true);
$clearance = cache()->get('airport.clearance');
$this->assertNotNull($clearance);
$this->assertSame('model', $clearance['source']);
$this->assertSame('C10-310 autorizado, pista 09.', $clearance['text']);
$models = [];
Http::assertSentCount(3);
Http::assertSent(function ($request) use (&$models): bool {
$models[] = $request['model'];
return true;
});
$this->assertSame(config('ai.atc_model'), $models[0]);
$this->assertSame(config('ai.default_model'), $models[1]);
$this->assertSame('xiaomi/mimo-v2.6-flash', $models[2]);
$this->assertSame('xiaomi/mimo-v2.6-flash', config('ai.fallback_model'));
}
/**
* A model that echoes the prompt instead of speaking as the tower is
* rejected and the chain falls through to the next model.
*/
public function test_dispatch_rejects_prompt_echo_replies(): void
{
config(['ai.openrouter.api_key' => 'test-key']);
Http::fake([
'openrouter.ai/*' => Http::sequence()
->push(['choices' => [['message' => ['content' => 'The user wants a single short sentence in Portuguese, ATC style, authorizing takeoff of flight C10-138 (Pampa Air) on runway 36. Must respond only with the sentence, no quotes.']]]], 200)
->push(['choices' => [['message' => ['content' => 'C10-138, Pampa Air, autorizado para decolagem na pista 36.']]]], 200),
]);
$this->postJson('/city/airport/dispatch')
->assertStatus(202)
->assertJsonPath('accepted', true);
$clearance = cache()->get('airport.clearance');
$this->assertNotNull($clearance);
$this->assertSame('model', $clearance['source']);
$this->assertSame('C10-138, Pampa Air, autorizado para decolagem na pista 36.', $clearance['text']);
Http::assertSentCount(2);
}
}