- Add two airports (north/south) with full geometry: runways, taxiways,
aprons, terminals, hangars, stands, and helipads; walkable airport
grounds extend beyond city bounds.
- Introduce ATC AI agent ("Torre de Controle City10") using AI_ATC_MODEL
to generate takeoff clearances; includes dispatch endpoint with
30-second cooldown and deterministic fallback on failure.
- Implement AI model fallback chain: preferred model → AI_FALLBACK_MODEL
before failing, applied in ChatController and PersonTalkController.
- Add luminous sign labels (letreiros) for plazas, beach, food, hospital,
school, city hall, and airports with day/night glow.
- Fix city presence cache: compute is_me per viewer instead of baking
into shared per-second cache to prevent cross-user flag leakage.
- Add tests: AirportTest, CityPresenceTest, SignLabelsTest, and
PersonTalkTest fallback model retry scenario.
689 lines
24 KiB
PHP
689 lines
24 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Http\Controllers\Web\PersonTalkController;
|
|
use App\Models\AiAgent;
|
|
use App\Models\Character;
|
|
use App\Models\PersonTalk;
|
|
use App\Models\User;
|
|
use Database\Seeders\CityWorldSeeder;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Db;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Str;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* Daily street conversations (bump-to-talk) coverage: registration per
|
|
* day, peer validation, agent replies with multi-day context and PII
|
|
* kept out of upstream payloads.
|
|
*/
|
|
class PersonTalkTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
/**
|
|
* Seed world fixtures.
|
|
*/
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->seed(CityWorldSeeder::class);
|
|
}
|
|
|
|
/**
|
|
* Create an active user paired with its in-world character.
|
|
*/
|
|
private function makeCitizen(): array
|
|
{
|
|
$user = User::factory()->create([
|
|
'status' => User::STATUS_ACTIVE,
|
|
'is_active' => true,
|
|
]);
|
|
|
|
return [$user, Character::ensureForUser($user)];
|
|
}
|
|
|
|
/**
|
|
* Simulate a human actively using the account (fresh session row).
|
|
*/
|
|
private function makePeerLive(User $user): void
|
|
{
|
|
Db::table('sessions')->insert([
|
|
'id' => Str::random(40),
|
|
'user_id' => $user->id,
|
|
'ip_address' => '127.0.0.1',
|
|
'user_agent' => 'phpunit',
|
|
'payload' => '',
|
|
'last_activity' => now()->getTimestamp(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Character-to-character talks are stored per day and visible from
|
|
* both sides of the conversation.
|
|
*/
|
|
public function test_character_talk_is_registered_per_day_for_both_sides(): void
|
|
{
|
|
[$userA, $me] = $this->makeCitizen();
|
|
[, $them] = $this->makeCitizen();
|
|
|
|
$this->actingAs($userA)
|
|
->postJson('/talk/person', [
|
|
'kind' => 'character',
|
|
'peer' => $them->id,
|
|
'message' => 'Oi, tudo bem?',
|
|
])
|
|
->assertCreated()
|
|
->assertJsonPath('from', 'me')
|
|
->assertJsonPath('talked_on', now()->toDateString());
|
|
|
|
$this->assertDatabaseHas('person_talks', [
|
|
'from_character_id' => $me->id,
|
|
'to_character_id' => $them->id,
|
|
'to_agent_id' => null,
|
|
'body' => 'Oi, tudo bem?',
|
|
]);
|
|
$stored = PersonTalk::query()->where('body', 'Oi, tudo bem?')->firstOrFail();
|
|
$this->assertSame(now()->toDateString(), $stored->talked_on->toDateString());
|
|
}
|
|
|
|
/**
|
|
* The peer reads the same row as an incoming message and can answer
|
|
* back through the same endpoint.
|
|
*/
|
|
public function test_conversation_is_shared_between_both_characters(): void
|
|
{
|
|
[$userA, $me] = $this->makeCitizen();
|
|
[$userB, $them] = $this->makeCitizen();
|
|
|
|
// Both sides driven by live humans → pure relay, no auto-replies.
|
|
$this->makePeerLive($userA);
|
|
$this->makePeerLive($userB);
|
|
|
|
$this->actingAs($userA)
|
|
->postJson('/talk/person', [
|
|
'kind' => 'character',
|
|
'peer' => $them->id,
|
|
'message' => 'Oi, tudo bem?',
|
|
])
|
|
->assertCreated();
|
|
|
|
$this->actingAs($userB)
|
|
->postJson('/talk/person', [
|
|
'kind' => 'character',
|
|
'peer' => $me->id,
|
|
'message' => 'Tudo ótimo!',
|
|
])
|
|
->assertCreated();
|
|
|
|
$this->actingAs($userB)
|
|
->getJson('/talk/person?kind=character&peer='.$me->id)
|
|
->assertOk()
|
|
->assertJsonPath('peer.kind', 'character')
|
|
->assertJsonPath('messages.0.from', 'them')
|
|
->assertJsonPath('messages.0.body', 'Oi, tudo bem?')
|
|
->assertJsonPath('messages.1.from', 'me')
|
|
->assertJsonPath('messages.1.body', 'Tudo ótimo!');
|
|
}
|
|
|
|
/**
|
|
* Peer and payload validation on the send endpoint.
|
|
*/
|
|
public function test_talk_validates_peer_and_message(): void
|
|
{
|
|
[$userA, $me] = $this->makeCitizen();
|
|
|
|
$this->actingAs($userA)
|
|
->postJson('/talk/person', [
|
|
'kind' => 'character',
|
|
'peer' => $me->id,
|
|
'message' => 'oi',
|
|
])
|
|
->assertStatus(422);
|
|
|
|
$this->actingAs($userA)
|
|
->postJson('/talk/person', [
|
|
'kind' => 'alien',
|
|
'peer' => 1,
|
|
'message' => 'oi',
|
|
])
|
|
->assertStatus(422);
|
|
|
|
$this->actingAs($userA)
|
|
->postJson('/talk/person', [
|
|
'kind' => 'character',
|
|
'peer' => 999999,
|
|
'message' => 'oi',
|
|
])
|
|
->assertNotFound();
|
|
|
|
$this->actingAs($userA)
|
|
->postJson('/talk/person', [
|
|
'kind' => 'character',
|
|
'peer' => 999999,
|
|
])
|
|
->assertStatus(422);
|
|
}
|
|
|
|
/**
|
|
* Agent talks store the message, answer after the response and send
|
|
* the multi-day history upstream while keeping PII out (RULES.md 2.2).
|
|
*/
|
|
public function test_agent_talk_replies_using_daily_history_as_context(): void
|
|
{
|
|
config(['ai.openrouter.api_key' => 'test-key']);
|
|
|
|
Http::fake([
|
|
'openrouter.ai/*' => Http::response([
|
|
'choices' => [['message' => ['content' => 'Praça renovada, como combinamos ontem!']]],
|
|
]),
|
|
]);
|
|
|
|
[$userA, $me] = $this->makeCitizen();
|
|
$agent = AiAgent::create(['name' => 'Prefeito', 'type' => 'mayor']);
|
|
|
|
// Yesterday's exchange is part of the persistent context.
|
|
PersonTalk::create([
|
|
'from_character_id' => $me->id,
|
|
'to_agent_id' => $agent->id,
|
|
'body' => 'Ontem conversamos sobre a praça',
|
|
'talked_on' => now()->subDay()->toDateString(),
|
|
]);
|
|
|
|
$this->actingAs($userA)
|
|
->postJson('/talk/person', [
|
|
'kind' => 'agent',
|
|
'peer' => $agent->id,
|
|
'message' => 'E a praça, fica pronta hoje?',
|
|
])
|
|
->assertCreated();
|
|
|
|
// The after-response job ran during the request lifecycle.
|
|
$this->assertDatabaseHas('person_talks', [
|
|
'from_agent_id' => $agent->id,
|
|
'to_character_id' => $me->id,
|
|
'body' => 'Praça renovada, como combinamos ontem!',
|
|
]);
|
|
$reply = PersonTalk::query()->where('body', 'Praça renovada, como combinamos ontem!')->firstOrFail();
|
|
$this->assertSame(now()->toDateString(), $reply->talked_on->toDateString());
|
|
|
|
$agent->refresh();
|
|
$this->assertSame('active', $agent->status);
|
|
$this->assertSame(0, (int) $agent->error_count);
|
|
$this->assertNotNull($agent->last_think_at);
|
|
$this->assertNotEmpty($agent->last_decision);
|
|
|
|
$userEmail = $userA->email;
|
|
$userName = $userA->name;
|
|
Http::assertSent(function ($request) use ($userEmail, $userName): bool {
|
|
$payload = $request->data();
|
|
$encoded = json_encode($payload, JSON_UNESCAPED_UNICODE);
|
|
|
|
// Yesterday's line and today's line reach the model (thread kept).
|
|
$hasHistory = str_contains($encoded, 'Ontem conversamos sobre a praça')
|
|
&& str_contains($encoded, 'E a praça, fica pronta hoje?');
|
|
// No registration data upstream (RULES.md 2.2).
|
|
$leaksPii = str_contains($encoded, $userEmail)
|
|
|| str_contains($encoded, $userName);
|
|
|
|
return $hasHistory && ! $leaksPii;
|
|
});
|
|
|
|
$this->actingAs($userA)
|
|
->getJson('/talk/person?kind=agent&peer='.$agent->id)
|
|
->assertOk()
|
|
->assertJsonPath('peer.kind', 'agent')
|
|
->assertJsonPath('peer.status', 'active')
|
|
->assertJsonPath('thinking', false)
|
|
->assertJsonPath('messages.0.body', 'Ontem conversamos sobre a praça')
|
|
->assertJsonPath('messages.0.from', 'me')
|
|
->assertJsonPath('messages.1.body', 'E a praça, fica pronta hoje?')
|
|
->assertJsonPath('messages.2.body', 'Praça renovada, como combinamos ontem!')
|
|
->assertJsonPath('messages.2.from', 'them')
|
|
->assertJsonPath('messages.2.talked_on', now()->toDateString());
|
|
}
|
|
|
|
/**
|
|
* Without an upstream key the conversation still completes: the
|
|
* agent stores the localized fallback and counts the failure for
|
|
* backoff (RULES.md 2.4).
|
|
*/
|
|
public function test_agent_talk_without_api_key_stores_fallback_reply(): void
|
|
{
|
|
config(['ai.openrouter.api_key' => null]);
|
|
Http::preventStrayRequests();
|
|
|
|
[$userA, $me] = $this->makeCitizen();
|
|
$agent = AiAgent::create(['name' => 'Lojista', 'type' => 'shopkeeper']);
|
|
|
|
$this->actingAs($userA)
|
|
->postJson('/talk/person', [
|
|
'kind' => 'agent',
|
|
'peer' => $agent->id,
|
|
'message' => 'Bom dia!',
|
|
])
|
|
->assertCreated();
|
|
|
|
$this->assertDatabaseHas('person_talks', [
|
|
'from_agent_id' => $agent->id,
|
|
'to_character_id' => $me->id,
|
|
'body' => __('city.chat_error'),
|
|
'is_fallback' => true,
|
|
]);
|
|
$fallback = PersonTalk::query()->where('body', __('city.chat_error'))->firstOrFail();
|
|
$this->assertSame(now()->toDateString(), $fallback->talked_on->toDateString());
|
|
$this->assertTrue((bool) $fallback->is_fallback);
|
|
|
|
$agent->refresh();
|
|
$this->assertSame(1, (int) $agent->error_count);
|
|
|
|
$this->actingAs($userA)
|
|
->getJson('/talk/person?kind=agent&peer='.$agent->id)
|
|
->assertOk()
|
|
->assertJsonPath('thinking', false);
|
|
}
|
|
|
|
/**
|
|
* While the agent reply is still pending the UI is told to keep
|
|
* showing the thinking state.
|
|
*/
|
|
public function test_agent_talk_reports_thinking_until_reply_lands(): void
|
|
{
|
|
[$userA, $me] = $this->makeCitizen();
|
|
$agent = AiAgent::create(['name' => 'Guia', 'type' => 'pedestrian']);
|
|
|
|
// Pending row without dispatching a reply (simulate in-flight).
|
|
PersonTalk::create([
|
|
'from_character_id' => $me->id,
|
|
'to_agent_id' => $agent->id,
|
|
'body' => 'Oi!',
|
|
'talked_on' => now()->toDateString(),
|
|
]);
|
|
|
|
$this->actingAs($userA)
|
|
->getJson('/talk/person?kind=agent&peer='.$agent->id)
|
|
->assertOk()
|
|
->assertJsonPath('thinking', true);
|
|
}
|
|
|
|
/**
|
|
* History spans multiple days so later conversations keep the thread.
|
|
*/
|
|
public function test_history_returns_previous_days_in_order(): void
|
|
{
|
|
[$userA, $me] = $this->makeCitizen();
|
|
[, $them] = $this->makeCitizen();
|
|
|
|
foreach ([
|
|
[now()->subDays(2), 'Primeira conversa'],
|
|
[now()->subDay(), 'Segunda conversa'],
|
|
[now(), 'Terceira conversa'],
|
|
] as [$day, $body]) {
|
|
PersonTalk::create([
|
|
'from_character_id' => $me->id,
|
|
'to_character_id' => $them->id,
|
|
'body' => $body,
|
|
'talked_on' => $day->toDateString(),
|
|
]);
|
|
}
|
|
|
|
$response = $this->actingAs($userA)
|
|
->getJson('/talk/person?kind=character&peer='.$them->id)
|
|
->assertOk();
|
|
|
|
$messages = $response->json('messages');
|
|
$this->assertCount(3, $messages);
|
|
$this->assertSame('Primeira conversa', $messages[0]['body']);
|
|
$this->assertSame(now()->subDays(2)->toDateString(), $messages[0]['talked_on']);
|
|
$this->assertSame('Terceira conversa', $messages[2]['body']);
|
|
$this->assertSame(now()->toDateString(), $messages[2]['talked_on']);
|
|
}
|
|
|
|
/**
|
|
* A character peer without a live human session (seeded citizen,
|
|
* demo account like "Maria") answers through the AI persona, using
|
|
* the daily history as context and keeping PII out of the payload.
|
|
*/
|
|
public function test_silent_citizen_peer_answers_with_ai_reply(): void
|
|
{
|
|
config(['ai.openrouter.api_key' => 'test-key']);
|
|
|
|
Http::fake([
|
|
'openrouter.ai/*' => Http::response([
|
|
'choices' => [['message' => ['content' => 'Oi! Tudo tranquilo na cidade hoje.']]],
|
|
]),
|
|
]);
|
|
|
|
[$userA, $me] = $this->makeCitizen();
|
|
[$userB, $them] = $this->makeCitizen(); // no session → silent peer
|
|
|
|
PersonTalk::create([
|
|
'from_character_id' => $me->id,
|
|
'to_character_id' => $them->id,
|
|
'body' => 'Ontem te vi na praça',
|
|
'talked_on' => now()->subDay()->toDateString(),
|
|
]);
|
|
|
|
$this->actingAs($userA)
|
|
->postJson('/talk/person', [
|
|
'kind' => 'character',
|
|
'peer' => $them->id,
|
|
'message' => 'Oi, tudo bem por aí?',
|
|
])
|
|
->assertCreated();
|
|
|
|
// The silent peer replied during the after-response phase.
|
|
$reply = PersonTalk::query()
|
|
->where('from_character_id', $them->id)
|
|
->where('to_character_id', $me->id)
|
|
->first();
|
|
$this->assertNotNull($reply);
|
|
$this->assertSame('Oi! Tudo tranquilo na cidade hoje.', $reply->body);
|
|
$this->assertSame(now()->toDateString(), $reply->talked_on->toDateString());
|
|
|
|
$peerEmail = $userB->email;
|
|
Http::assertSent(function ($request) use ($peerEmail): bool {
|
|
$encoded = json_encode($request->data(), JSON_UNESCAPED_UNICODE);
|
|
|
|
// Thread kept… and no registration data upstream (RULES.md 2.2).
|
|
return str_contains($encoded, 'Ontem te vi na praça')
|
|
&& str_contains($encoded, 'Oi, tudo bem por aí?')
|
|
&& ! str_contains($encoded, $peerEmail);
|
|
});
|
|
|
|
$this->actingAs($userA)
|
|
->getJson('/talk/person?kind=character&peer='.$them->id)
|
|
->assertOk()
|
|
->assertJsonPath('thinking', false);
|
|
}
|
|
|
|
/**
|
|
* A peer driven by a live human session is relayed as-is and never
|
|
* answered by the AI on their behalf.
|
|
*/
|
|
public function test_live_human_peer_is_not_impersonated(): void
|
|
{
|
|
config(['ai.openrouter.api_key' => 'test-key']);
|
|
Http::preventStrayRequests();
|
|
|
|
[$userA, $me] = $this->makeCitizen();
|
|
[$userB, $them] = $this->makeCitizen();
|
|
$this->makePeerLive($userB);
|
|
|
|
$this->actingAs($userA)
|
|
->postJson('/talk/person', [
|
|
'kind' => 'character',
|
|
'peer' => $them->id,
|
|
'message' => 'Você está aí?',
|
|
])
|
|
->assertCreated();
|
|
|
|
$this->assertSame(0, PersonTalk::query()
|
|
->where('from_character_id', $them->id)
|
|
->where('to_character_id', $me->id)
|
|
->count());
|
|
|
|
$this->actingAs($userA)
|
|
->getJson('/talk/person?kind=character&peer='.$them->id)
|
|
->assertOk()
|
|
->assertJsonPath('thinking', false);
|
|
}
|
|
|
|
/**
|
|
* Without an API key the silent peer still answers with the stored
|
|
* fallback so the chat never hangs waiting.
|
|
*/
|
|
public function test_silent_citizen_falls_back_without_api_key(): void
|
|
{
|
|
config(['ai.openrouter.api_key' => null]);
|
|
Http::preventStrayRequests();
|
|
|
|
[$userA, $me] = $this->makeCitizen();
|
|
[, $them] = $this->makeCitizen();
|
|
|
|
$this->actingAs($userA)
|
|
->postJson('/talk/person', [
|
|
'kind' => 'character',
|
|
'peer' => $them->id,
|
|
'message' => 'Olá?',
|
|
])
|
|
->assertCreated();
|
|
|
|
$this->assertDatabaseHas('person_talks', [
|
|
'from_character_id' => $them->id,
|
|
'to_character_id' => $me->id,
|
|
'body' => __('city.chat_error'),
|
|
'is_fallback' => true,
|
|
]);
|
|
|
|
$this->actingAs($userA)
|
|
->getJson('/talk/person?kind=character&peer='.$them->id)
|
|
->assertOk()
|
|
->assertJsonPath('thinking', false);
|
|
}
|
|
|
|
/**
|
|
* Error-fallback rows stay visible in the chat but are never sent
|
|
* back to the model as dialogue — an apology in the context would
|
|
* skew the persona into a support-bot tone or identity confusion.
|
|
*/
|
|
public function test_fallback_rows_are_excluded_from_model_context(): void
|
|
{
|
|
config(['ai.openrouter.api_key' => 'test-key']);
|
|
|
|
Http::fake([
|
|
'openrouter.ai/*' => Http::response([
|
|
'choices' => [['message' => ['content' => 'Praça fechada para obra, já passa.']]],
|
|
]),
|
|
]);
|
|
|
|
[$userA, $me] = $this->makeCitizen();
|
|
[$userB, $them] = $this->makeCitizen();
|
|
|
|
// Yesterday: a real line plus an error placeholder.
|
|
PersonTalk::create([
|
|
'from_character_id' => $me->id,
|
|
'to_character_id' => $them->id,
|
|
'body' => 'E a praça, abre hoje?',
|
|
'talked_on' => now()->subDay()->toDateString(),
|
|
]);
|
|
PersonTalk::create([
|
|
'from_character_id' => $them->id,
|
|
'to_character_id' => $me->id,
|
|
'body' => __('city.chat_error'),
|
|
'talked_on' => now()->subDay()->toDateString(),
|
|
'is_fallback' => true,
|
|
]);
|
|
|
|
$this->actingAs($userA)
|
|
->postJson('/talk/person', [
|
|
'kind' => 'character',
|
|
'peer' => $them->id,
|
|
'message' => 'E aí, e a obra?',
|
|
])
|
|
->assertCreated();
|
|
|
|
Http::assertSent(function ($request): bool {
|
|
$encoded = json_encode($request->data(), JSON_UNESCAPED_UNICODE);
|
|
|
|
return str_contains($encoded, 'E a praça, abre hoje?')
|
|
&& str_contains($encoded, 'E aí, e a obra?')
|
|
&& ! str_contains($encoded, __('city.chat_error'));
|
|
});
|
|
|
|
// The stored reply is a real answer, not another placeholder.
|
|
$reply = PersonTalk::query()
|
|
->where('from_character_id', $them->id)
|
|
->where('is_fallback', false)
|
|
->orderByDesc('id')
|
|
->first();
|
|
$this->assertNotNull($reply);
|
|
$this->assertSame('Praça fechada para obra, já passa.', $reply->body);
|
|
}
|
|
|
|
/**
|
|
* Model resolution priority: column → env map (id/name) → pool slot,
|
|
* always inside the allow-list (RULES.md 2.1) and stable per id.
|
|
*/
|
|
public function test_model_resolution_priority_and_allow_list(): void
|
|
{
|
|
$mapped = 'openrouter/free';
|
|
$pooled = 'nvidia/nemotron-3.5-lightning:free';
|
|
|
|
config([
|
|
'ai.models' => [$pooled, $mapped],
|
|
'ai.character_models' => [
|
|
'maria' => $mapped,
|
|
'7' => $pooled,
|
|
'bad' => 'not/allowed-model',
|
|
],
|
|
]);
|
|
|
|
// 1) Character column wins over everything.
|
|
$this->assertSame(
|
|
$mapped,
|
|
PersonTalkController::resolveModel($mapped, 'Qualquer', 99)
|
|
);
|
|
|
|
// 2) Env map by name (case-insensitive) and by id.
|
|
$this->assertSame($mapped, PersonTalkController::resolveModel(null, 'Maria', 3));
|
|
$this->assertSame($pooled, PersonTalkController::resolveModel(null, 'Outro', 7));
|
|
|
|
// 3) Non allow-listed entries are ignored → deterministic pool slot.
|
|
$fromPool = PersonTalkController::resolveModel('not/allowed-model', 'Bad', 4);
|
|
$this->assertContains($fromPool, [$pooled, $mapped]);
|
|
$this->assertNotSame('not/allowed-model', $fromPool);
|
|
|
|
// 4) Empty map/pool falls back to the allow-listed default.
|
|
config(['ai.character_models' => [], 'ai.models' => []]);
|
|
$default = config('ai.default_model');
|
|
$this->assertContains($default, config('ai.allowed_models'));
|
|
$this->assertSame($default, PersonTalkController::resolveModel(null, 'X', 1));
|
|
|
|
// 5) Same character always resolves to the same model.
|
|
config(['ai.models' => [$pooled, $mapped]]);
|
|
$stable = PersonTalkController::resolveModel(null, 'Estável', 5);
|
|
$this->assertSame($stable, PersonTalkController::resolveModel(null, 'Estável', 5));
|
|
}
|
|
|
|
/**
|
|
* The identity/personality profile and the resolved model travel in
|
|
* the upstream payload while registration data stays out (RULES.md 2.2).
|
|
*/
|
|
public function test_identity_and_model_flow_into_upstream_payload(): void
|
|
{
|
|
config([
|
|
'ai.openrouter.api_key' => 'test-key',
|
|
'ai.models' => ['liquid/lfm-2.5-2.6b:free'],
|
|
'ai.character_models' => [],
|
|
]);
|
|
|
|
Http::fake([
|
|
'openrouter.ai/*' => Http::response([
|
|
'choices' => [['message' => ['content' => 'Oi! Tudo tranquilo na cidade.']]],
|
|
]),
|
|
]);
|
|
|
|
[$userA, $me] = $this->makeCitizen();
|
|
[$userB, $them] = $this->makeCitizen();
|
|
|
|
$them->update([
|
|
'ai_age' => 34,
|
|
'ai_gender' => 'feminino',
|
|
'ai_personality' => 'Extrovertido e curiosa, adora puxar conversa.',
|
|
'ai_bio' => 'Moradora do centro, vive nas novidades do bairro.',
|
|
]);
|
|
|
|
$this->actingAs($userA)
|
|
->postJson('/talk/person', [
|
|
'kind' => 'character',
|
|
'peer' => $them->id,
|
|
'message' => 'Oi, tudo bem?',
|
|
])
|
|
->assertCreated();
|
|
|
|
$peerEmail = $userB->email;
|
|
$peerName = $them->name;
|
|
Http::assertSent(function ($request) use ($peerEmail, $peerName): bool {
|
|
$payload = $request->data();
|
|
$encoded = json_encode($payload, JSON_UNESCAPED_UNICODE);
|
|
$system = (string) ($payload['messages'][0]['content'] ?? '');
|
|
|
|
return $payload['model'] === 'liquid/lfm-2.5-2.6b:free'
|
|
&& str_contains($system, 'Idade: 34')
|
|
&& str_contains($system, 'Sexo: feminino')
|
|
&& str_contains($system, 'Extrovertido e curiosa')
|
|
&& str_contains($system, 'Moradora do centro')
|
|
&& str_contains($system, 'Nome: '.$peerName)
|
|
&& ! str_contains($encoded, $peerEmail);
|
|
});
|
|
|
|
// The reply from the fake model is stored normally.
|
|
$this->assertNotNull(PersonTalk::query()
|
|
->where('from_character_id', $them->id)
|
|
->where('body', 'Oi! Tudo tranquilo na cidade.')
|
|
->first());
|
|
}
|
|
|
|
/**
|
|
* Fresh citizens are created with a ready-to-use default persona.
|
|
*/
|
|
public function test_new_characters_receive_default_persona(): void
|
|
{
|
|
[, $character] = $this->makeCitizen();
|
|
|
|
$this->assertNotNull($character->ai_age);
|
|
$this->assertGreaterThanOrEqual(18, $character->ai_age);
|
|
$this->assertLessThanOrEqual(68, $character->ai_age);
|
|
$this->assertContains($character->ai_personality, Character::AI_PERSONALITIES);
|
|
}
|
|
|
|
/**
|
|
* When the resolved primary model fails, the call retries with
|
|
* AI_FALLBACK_MODEL (owner-approved xiaomi/mimo-v2.6-flash).
|
|
*/
|
|
public function test_person_talk_retries_with_fallback_model(): void
|
|
{
|
|
config([
|
|
'ai.openrouter.api_key' => 'test-key',
|
|
'ai.models' => ['openrouter/free'],
|
|
'ai.character_models' => [],
|
|
]);
|
|
|
|
Http::fake([
|
|
'openrouter.ai/*' => Http::sequence()
|
|
->push(['message' => 'Model overloaded'], 503)
|
|
->push(['choices' => [['message' => ['content' => 'Tudo bem por aqui!']]]], 200),
|
|
]);
|
|
|
|
[$userA, $me] = $this->makeCitizen();
|
|
[, $them] = $this->makeCitizen(); // silent peer → AI reply path
|
|
|
|
$this->actingAs($userA)
|
|
->postJson('/talk/person', [
|
|
'kind' => 'character',
|
|
'peer' => $them->id,
|
|
'message' => 'Alô?',
|
|
])
|
|
->assertCreated();
|
|
|
|
$reply = PersonTalk::query()
|
|
->where('from_character_id', $them->id)
|
|
->where('is_fallback', false)
|
|
->orderByDesc('id')
|
|
->first();
|
|
$this->assertNotNull($reply);
|
|
$this->assertSame('Tudo bem por aqui!', $reply->body);
|
|
|
|
$models = [];
|
|
Http::assertSent(function ($request) use (&$models): bool {
|
|
$models[] = $request['model'];
|
|
|
|
return true;
|
|
});
|
|
$this->assertSame(['openrouter/free', 'xiaomi/mimo-v2.6-flash'], $models);
|
|
}
|
|
}
|