Skip to content

Commit

Permalink
refactor LearningService - extract dependencies out to separated clas…
Browse files Browse the repository at this point in the history
…ses, cover with unit tests
  • Loading branch information
rtrzebinski-usc authored and rtrzebinski committed Oct 4, 2020
1 parent d0879c4 commit 2a41410
Show file tree
Hide file tree
Showing 7 changed files with 1,328 additions and 1,007 deletions.
21 changes: 21 additions & 0 deletions app/Services/ArrayRandomizer.php
@@ -0,0 +1,21 @@
<?php

namespace App\Services;

/**
* Extract getting a random element of an array, so it can me mocked for testing.
*
* Class RandomizationService
* @package App\Services
*/
class ArrayRandomizer
{
/**
* @param array $items
* @return mixed
*/
public function randomArrayElement(array $items)
{
return $items[array_rand($items)];
}
}
138 changes: 17 additions & 121 deletions app/Services/LearningService.php
Expand Up @@ -4,7 +4,6 @@

use App\Structures\UserExercise\AuthenticatedUserExerciseRepositoryInterface;
use App\Structures\UserExercise\UserExercise;
use Carbon\Carbon;
use Exception;
use Illuminate\Support\Collection;

Expand All @@ -19,14 +18,23 @@
class LearningService
{
private AuthenticatedUserExerciseRepositoryInterface $authenticatedUserExerciseRepository;
private PointsCalculator $pointsCalculator;
private ArrayRandomizer $arrayRandomizer;

/**
* LearningService constructor.
* @param AuthenticatedUserExerciseRepositoryInterface $authenticatedUserExerciseRepository
* @param PointsCalculator $pointsCalculator
* @param ArrayRandomizer $randomizationService
*/
public function __construct(AuthenticatedUserExerciseRepositoryInterface $authenticatedUserExerciseRepository)
{
public function __construct(
AuthenticatedUserExerciseRepositoryInterface $authenticatedUserExerciseRepository,
PointsCalculator $pointsCalculator,
ArrayRandomizer $randomizationService
) {
$this->authenticatedUserExerciseRepository = $authenticatedUserExerciseRepository;
$this->pointsCalculator = $pointsCalculator;
$this->arrayRandomizer = $randomizationService;
}

/**
Expand All @@ -51,29 +59,29 @@ function (UserExercise $userExercise) use ($previousExerciseId) {
return null;
}

$tmp = [];
$keys = [];

foreach ($userExercises as $key => $userExercise) {
$points = $this->calculatePoints($userExercise);
$points = $this->pointsCalculator->calculatePoints($userExercise);

/*
* Fill $tmp array with exercises $key multiplied by number of points.
* This way exercises with higher number of points (so lower user knowledge) have bigger chance to be returned.
*/
for ($i = $points; $i > 0; $i--) {
$tmp[] = $key;
$keys[] = $key;
}
}

// all exercises have 0 points - none should be returned (served)
if (empty($tmp)) {
if (empty($keys)) {
// but perhaps previous has some points? let's check
if ($previousExerciseId) {
$previousUserExercise = $this->authenticatedUserExerciseRepository->fetchUserExerciseOfExercise(
$previousExerciseId
);

$previousPoints = $this->calculatePoints($previousUserExercise);
$previousPoints = $this->pointsCalculator->calculatePoints($previousUserExercise);

// if previous has points, but no other exercise have points,
// let's keep serving previous until user says he knows it,
Expand All @@ -86,118 +94,6 @@ function (UserExercise $userExercise) use ($previousExerciseId) {
return null;
}

return $userExercises[$tmp[array_rand($tmp)]];
}

/**
* Calculate points for given exercise result.
*
* @param UserExercise $userExercise
* @return int
* @throws Exception
*/
public function calculatePoints(UserExercise $userExercise): int
{
// no answers at all
if ($userExercise->number_of_good_answers == 0 && $userExercise->number_of_bad_answers == 0) {
// give question without any answers highest chance to be served
return 100;
}

/** @var Carbon|null $latestGoodAnswer */
$latestGoodAnswer = $userExercise->latest_good_answer ? new Carbon($userExercise->latest_good_answer) : null;

/** @var Carbon|null $latestBadAnswer */
$latestBadAnswer = $userExercise->latest_bad_answer ? new Carbon($userExercise->latest_bad_answer) : null;

// check for answers today first

// user had both good and bad answers today
if ($latestGoodAnswer instanceof Carbon && $latestBadAnswer instanceof Carbon && $latestGoodAnswer->isToday(
) && $latestBadAnswer->isToday()) {
// if good answer was the most recent - return 0 point to not bother user with this question anymore today
// it makes more sense to serve it another day than serve again today
if ($latestGoodAnswer->isAfter($latestBadAnswer)) {
return 0;
}
}

// user had just good answer today
if ($latestGoodAnswer instanceof Carbon && $latestGoodAnswer->isToday()) {
// return 0 point to not bother user with this question anymore today
// it makes more sense to serve it another day than serve again today
return 0;
}

// user had just bad answers today
if ($latestBadAnswer instanceof Carbon && $latestBadAnswer->isToday()) {
// first check whether 'max_exercise_bad_answers_per_day' was reached
// if was return 0 points, so exercise is not served
if ($userExercise->number_of_bad_answers_today >= config('app.max_exercise_bad_answers_per_day')) {
return 0;
}

// here we decrease points with incoming bad answers today
// so user is not overloaded with this question
// but he still see it once a while
if ($userExercise->number_of_bad_answers_today == 1) {
return 80;
}

if ($userExercise->number_of_bad_answers_today == 2) {
return 50;
}

if ($userExercise->number_of_bad_answers_today >= 3) {
return 20;
}
}

// no answers today - check for answers of just one type

// only good answers exist, but none today
if ($userExercise->number_of_bad_answers == 0 && $userExercise->number_of_good_answers > 0) {
if ($userExercise->number_of_good_answers == 1) {
return 80;
}

if ($userExercise->number_of_good_answers == 2) {
return 50;
}

if ($userExercise->number_of_good_answers == 3) {
return 20;
}

return 1;
}

// no answers with just one type - calculate points

// calculate points based on percent of good answers, so if user did not answer this exercise today,
// we want to calculate points based on the ration of previous good and bad answers
return $this->convertPercentOfGoodAnswersToPoints($userExercise->percent_of_good_answers);
}

/**
* Will return number of points related to percent of good answer.
* For percent of good answer = 0 return 100 points (maximum).
* For percent of good answer = 100 return 1 point (minimum).
* For percent of good answer = 20 return 80 points.
* For percent of good answer = 50 return 50 points.
* For percent of good answer = 90 return 10 points.
* etc.
*
* @param int $percentOfGoodAnswers
* @return int
* @throws \Exception
*/
private function convertPercentOfGoodAnswersToPoints(int $percentOfGoodAnswers): int
{
if ($percentOfGoodAnswers == 100) {
return 1;
}

return (100 - $percentOfGoodAnswers);
return $userExercises[$this->arrayRandomizer->randomArrayElement($keys)];
}
}
122 changes: 122 additions & 0 deletions app/Services/PointsCalculator.php
@@ -0,0 +1,122 @@
<?php


namespace App\Services;

use App\Structures\UserExercise\UserExercise;
use Carbon\Carbon;

class PointsCalculator
{
/**
* Calculate points for given exercise result.
*
* @param UserExercise $userExercise
* @return int
* @throws \Exception
*/
public function calculatePoints(UserExercise $userExercise): int
{
// no answers at all
if ($userExercise->number_of_good_answers == 0 && $userExercise->number_of_bad_answers == 0) {
// give question without any answers highest chance to be served
return 100;
}

/** @var Carbon|null $latestGoodAnswer */
$latestGoodAnswer = $userExercise->latest_good_answer ? new Carbon($userExercise->latest_good_answer) : null;

/** @var Carbon|null $latestBadAnswer */
$latestBadAnswer = $userExercise->latest_bad_answer ? new Carbon($userExercise->latest_bad_answer) : null;

// check for answers today first

// user had both good and bad answers today
if ($latestGoodAnswer instanceof Carbon && $latestBadAnswer instanceof Carbon && $latestGoodAnswer->isToday(
) && $latestBadAnswer->isToday()) {
// if good answer was the most recent - return 0 point to not bother user with this question anymore today
// it makes more sense to serve it another day than serve again today
if ($latestGoodAnswer->isAfter($latestBadAnswer)) {
return 0;
}
}

// user had just good answer today
if ($latestGoodAnswer instanceof Carbon && $latestGoodAnswer->isToday()) {
// return 0 point to not bother user with this question anymore today
// it makes more sense to serve it another day than serve again today
return 0;
}

// user had just bad answers today
if ($latestBadAnswer instanceof Carbon && $latestBadAnswer->isToday()) {
// first check whether 'max_exercise_bad_answers_per_day' was reached
// if was return 0 points, so exercise is not served
if ($userExercise->number_of_bad_answers_today >= config('app.max_exercise_bad_answers_per_day')) {
return 0;
}

// here we decrease points with incoming bad answers today
// so user is not overloaded with this question
// but he still see it once a while
if ($userExercise->number_of_bad_answers_today == 1) {
return 80;
}

if ($userExercise->number_of_bad_answers_today == 2) {
return 50;
}

if ($userExercise->number_of_bad_answers_today >= 3) {
return 20;
}
}

// no answers today - check for answers of just one type

// only good answers exist, but none today
if ($userExercise->number_of_bad_answers == 0 && $userExercise->number_of_good_answers > 0) {
if ($userExercise->number_of_good_answers == 1) {
return 80;
}

if ($userExercise->number_of_good_answers == 2) {
return 50;
}

if ($userExercise->number_of_good_answers == 3) {
return 20;
}

return 1;
}

// no answers with just one type - calculate points

// calculate points based on percent of good answers, so if user did not answer this exercise today,
// we want to calculate points based on the ration of previous good and bad answers
return $this->convertPercentOfGoodAnswersToPoints($userExercise->percent_of_good_answers);
}

/**
* Will return number of points related to percent of good answer.
* For percent of good answer = 0 return 100 points (maximum).
* For percent of good answer = 100 return 1 point (minimum).
* For percent of good answer = 20 return 80 points.
* For percent of good answer = 50 return 50 points.
* For percent of good answer = 90 return 10 points.
* etc.
*
* @param int $percentOfGoodAnswers
* @return int
* @throws \Exception
*/
private function convertPercentOfGoodAnswersToPoints(int $percentOfGoodAnswers): int
{
if ($percentOfGoodAnswers == 100) {
return 1;
}

return (100 - $percentOfGoodAnswers);
}
}

0 comments on commit 2a41410

Please sign in to comment.