tests
This commit is contained in:
+11
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true">
|
||||
<testsuites>
|
||||
<testsuite name="Unit"><directory>tests/Unit</directory></testsuite>
|
||||
<testsuite name="Feature"><directory>tests/Feature</directory></testsuite>
|
||||
</testsuites>
|
||||
<source><include><directory>src</directory></include></source>
|
||||
</phpunit>
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Handler\MockHandler;
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use GuzzleHttp\Middleware;
|
||||
use GuzzleHttp\Psr7\HttpFactory;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use Laya\Exception\UnexpectedResponseException;
|
||||
use Laya\Laya;
|
||||
use Laya\LayaClient;
|
||||
use Laya\Question\QuestionSet;
|
||||
use Laya\Question\YesNoQuestion;
|
||||
|
||||
/** @return array{LayaClient, ArrayObject} client and the recorded request history */
|
||||
function fakeClient(array $responses): array
|
||||
{
|
||||
$history = new ArrayObject();
|
||||
$stack = HandlerStack::create(new MockHandler($responses));
|
||||
$stack->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);
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
use Laya\Result\DecisionSet;
|
||||
|
||||
function sampleResponse(): array
|
||||
{
|
||||
return [
|
||||
'routing' => ['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');
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
use Laya\Question\ChoiceQuestion;
|
||||
use Laya\Question\QuestionSet;
|
||||
use Laya\Question\ScoreQuestion;
|
||||
use Laya\Question\YesNoQuestion;
|
||||
|
||||
it('serialises a choice question', function () {
|
||||
$q = (new ChoiceQuestion('category', 'Which?'))->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 []');
|
||||
Reference in New Issue
Block a user