From 579d4874609eaf92e8543accbca9534d1653c89e Mon Sep 17 00:00:00 2001 From: Doar Mihai Date: Sat, 17 Oct 2026 02:01:43 +0300 Subject: [PATCH] tests --- phpunit.xml | 11 ++++ tests/Feature/LayaClientTest.php | 99 ++++++++++++++++++++++++++++++++ tests/Pest.php | 3 + tests/Unit/DecisionSetTest.php | 58 +++++++++++++++++++ tests/Unit/QuestionTest.php | 55 ++++++++++++++++++ 5 files changed, 226 insertions(+) create mode 100644 phpunit.xml create mode 100644 tests/Feature/LayaClientTest.php create mode 100644 tests/Pest.php create mode 100644 tests/Unit/DecisionSetTest.php create mode 100644 tests/Unit/QuestionTest.php diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..a1e3e8a --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,11 @@ + + + + tests/Unit + tests/Feature + + src + diff --git a/tests/Feature/LayaClientTest.php b/tests/Feature/LayaClientTest.php new file mode 100644 index 0000000..d91f4ed --- /dev/null +++ b/tests/Feature/LayaClientTest.php @@ -0,0 +1,99 @@ +push(Middleware::history($history)); + $factory = new HttpFactory(); + + return [new LayaClient(new Client(['handler' => $stack]), $factory, $factory, 'http://laya.test'), $history]; +} + +function json(array $body, int $status = 200): Response +{ + return new Response($status, ['Content-Type' => 'application/json'], json_encode($body)); +} + +function questions(): QuestionSet +{ + return QuestionSet::make()->add(new YesNoQuestion('angry', 'Upset?')); +} + +it('posts the state and questions to /predict', function () { + [$client, $history] = fakeClient([json(['answers' => ['angry' => ['type' => 'noul', 'noul' => 0.8]]])]); + + $result = $client->decide('hello', questions()); + + $request = $history[0]['request']; + expect($request->getMethod())->toBe('POST') + ->and((string) $request->getUri())->toBe('http://laya.test/predict') + ->and($request->getHeaderLine('Content-Type'))->toBe('application/json') + ->and($request->getHeaderLine('Accept'))->toBe('application/json') + ->and(json_decode((string) $request->getBody(), true))->toBe([ + 'state' => ['body' => 'hello'], + 'questions' => ['angry' => ['type' => 'noul', 'instructions' => 'Upset?']], + ]) + ->and($result->get('angry')->probability())->toBe(0.8); +}); + +it('sends an array state unchanged and includes the model when given', function () { + [$client, $history] = fakeClient([json(['answers' => []])]); + + $client->decide(['subject' => 's', 'body' => 'b'], questions(), 'my-model'); + + $sent = json_decode((string) $history[0]['request']->getBody(), true); + expect($sent['state'])->toBe(['subject' => 's', 'body' => 'b']) + ->and($sent['model'])->toBe('my-model'); +}); + +it('omits model when none is given', function () { + [$client, $history] = fakeClient([json(['answers' => []])]); + + $client->decide('x', questions()); + + expect(json_decode((string) $history[0]['request']->getBody(), true))->not->toHaveKey('model'); +}); + +it('gets /models', function () { + [$client, $history] = fakeClient([json(['models' => ['a', 'b']])]); + + expect($client->models())->toBe(['models' => ['a', 'b']]) + ->and($history[0]['request']->getMethod())->toBe('GET') + ->and((string) $history[0]['request']->getUri())->toBe('http://laya.test/models'); +}); + +it('throws UnexpectedResponseException with the server detail on HTTP errors', function () { + [$client] = fakeClient([json(['detail' => 'bad model'], 422)]); + + try { + $client->decide('x', questions()); + $this->fail('expected exception'); + } catch (UnexpectedResponseException $e) { + expect($e->statusCode())->toBe(422) + ->and($e->getMessage())->toBe('Laya returned HTTP 422: bad model'); + } +}); + +it('throws JsonException when the body is not JSON', function () { + [$client] = fakeClient([new Response(200, [], 'not json')]); + + $client->models(); +})->throws(JsonException::class); + +it('builds a client through discovery', function () { + expect(Laya::client('http://laya.test'))->toBeInstanceOf(LayaClient::class); +}); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..174d7fd --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,3 @@ + ['model' => 'm1'], + 'answers' => [ + 'category' => ['type' => 'choice', 'choice' => 'billing', 'confidence' => 0.9, 'distribution' => ['billing' => 0.9, 'bug' => 0.1]], + 'urgency' => ['type' => 'score', 'score' => '2', 'confidence' => 0.5], + 'angry' => ['type' => 'noul', 'noul' => 0.93], + ], + ]; +} + +it('parses each answer into a decision', function () { + $set = DecisionSet::fromResponse(sampleResponse()); + + expect($set->routedModel)->toBe('m1') + ->and($set->get('category')->choice())->toBe('billing') + ->and($set->get('category')->type)->toBe('choice') + ->and($set->get('category')->confidence())->toBe(0.9) + ->and($set->get('category')->distribution())->toBe(['billing' => 0.9, 'bug' => 0.1]) + ->and($set->get('urgency')->score())->toBe(2.0) + ->and($set->get('angry')->probability())->toBe(0.93); +}); + +it('returns null for fields a decision does not have', function () { + $d = DecisionSet::fromResponse(sampleResponse())->get('angry'); + + expect($d->choice())->toBeNull() + ->and($d->score())->toBeNull() + ->and($d->confidence())->toBeNull(); +}); + +it('handles a response with no answers or routing', function () { + $set = DecisionSet::fromResponse([]); + + expect($set->routedModel)->toBeNull(); + $set->get('x'); +})->throws(OutOfBoundsException::class, "No answer for question 'x'"); + +it('throws for an unknown question name', function () { + DecisionSet::fromResponse(sampleResponse())->get('missing'); +})->throws(OutOfBoundsException::class); + +it('uses unknown as the type when the server omits it', function () { + $set = DecisionSet::fromResponse(['answers' => ['q' => ['choice' => 'a']]]); + + expect($set->get('q')->type)->toBe('unknown'); +}); + +it('returns only the distribution field, not the whole payload', function () { + $d = DecisionSet::fromResponse(['answers' => ['q' => ['type' => 'choice', 'choice' => 'a']]])->get('q'); + + expect($d->distribution())->toBe([]); +})->todo('CODE_REVIEW #8: distribution() currently falls back to the whole answer'); diff --git a/tests/Unit/QuestionTest.php b/tests/Unit/QuestionTest.php new file mode 100644 index 0000000..acbcc7a --- /dev/null +++ b/tests/Unit/QuestionTest.php @@ -0,0 +1,55 @@ +option('a', 'First')->option('b', 'Second'); + + expect($q->name())->toBe('category') + ->and($q->toArray())->toBe([ + 'type' => 'choice', + 'instructions' => 'Which?', + 'criteria' => ['a' => 'First', 'b' => 'Second'], + ]); +}); + +it('serialises a score question', function () { + $q = new ScoreQuestion('urgency', 'How urgent?', ['low', 'high']); + + expect($q->toArray())->toBe([ + 'type' => 'score', + 'instructions' => 'How urgent?', + 'criteria' => ['low', 'high'], + ]); +}); + +it('serialises a yes/no question using the noul wire type', function () { + expect((new YesNoQuestion('angry', 'Upset?'))->toArray()) + ->toBe(['type' => 'noul', 'instructions' => 'Upset?']); +}); + +it('keys a question set by question name', function () { + $set = QuestionSet::make() + ->add(new YesNoQuestion('a', 'A?')) + ->add(new YesNoQuestion('b', 'B?')); + + expect(array_keys($set->toArray()))->toBe(['a', 'b']); +}); + +it('replaces an earlier question with the same name', function () { + $set = QuestionSet::make() + ->add(new YesNoQuestion('a', 'first')) + ->add(new YesNoQuestion('a', 'second')); + + expect($set->toArray())->toHaveCount(1) + ->and($set->toArray()['a']['instructions'])->toBe('second'); +}); + +it('encodes a choice question without options as a JSON object', function () { + $json = json_encode((new ChoiceQuestion('c', 'x'))->toArray()); + + expect($json)->toContain('"criteria":{}'); +})->todo('CODE_REVIEW #7: empty criteria currently encodes as []');