Rewrite tabulation #14

Merged
okorpheus merged 43 commits from rewrite-tabulation into master 2024-07-14 05:36:29 +00:00
17 changed files with 467 additions and 122 deletions
Showing only changes of commit 0eda3ab32e - Show all commits

101
app/Actions/EnterScore.php Normal file
View File

@ -0,0 +1,101 @@
<?php
/** @noinspection PhpUnhandledExceptionInspection */
/** @noinspection PhpMissingReturnTypeInspection */
namespace App\Actions;
use App\Exceptions\ScoreEntryException;
use App\Models\Entry;
use App\Models\ScoreSheet;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class EnterScore
{
// TODO implement this action every place that can save a score
public function __invoke(User $user, Entry $entry, array $scores): ScoreSheet
{
$scores = collect($scores);
$this->basicChecks($user, $entry, $scores);
$this->checkJudgeAssignment($user, $entry);
$this->checkForExistingScore($user, $entry);
$this->validateScoresSubmitted($entry, $scores);
$entry->removeFlag('no_show');
$newScoreSheet = ScoreSheet::create([
'user_id' => $user->id,
'entry_id' => $entry->id,
'subscores' => $this->subscoresForStorage($entry, $scores),
]);
return $newScoreSheet;
}
protected function subscoresForStorage(Entry $entry, Collection $scores)
{
$subscores = [];
foreach ($entry->audition->scoringGuide->subscores as $subscore) {
$subscores[$subscore->id] = [
'score' => $scores[$subscore->id],
'subscore_id' => $subscore->id,
'subscore_name' => $subscore->name,
];
}
return $subscores;
}
protected function checkForExistingScore(User $user, Entry $entry)
{
if (ScoreSheet::where('user_id', $user->id)->where('entry_id', $entry->id)->exists()) {
throw new ScoreEntryException('That judge has already entered scores for that entry');
}
}
protected function validateScoresSubmitted(Entry $entry, Collection $scores)
{
$subscoresRequired = $entry->audition->scoringGuide->subscores;
foreach ($subscoresRequired as $subscore) {
// check that there is an element in the $scores collection with the key = $subscore->id
if (! $scores->keys()->contains($subscore->id)) {
throw new ScoreEntryException('Invalid Score Submission');
}
if ($scores[$subscore->id] > $subscore->max_score) {
throw new ScoreEntryException('Supplied subscore exceeds maximum allowed');
}
}
}
protected function checkJudgeAssignment(User $user, Entry $entry)
{
$check = DB::table('room_user')
->where('room_id', $entry->audition->room_id)
->where('user_id', $user->id)->exists();
if (! $check) {
throw new ScoreEntryException('This judge is not assigned to judge this entry');
}
}
protected function basicChecks(User $user, Entry $entry, Collection $scores)
{
if (! $user->exists()) {
throw new ScoreEntryException('User does not exist');
}
if (! $entry->exists()) {
throw new ScoreEntryException('Entry does not exist');
}
if ($entry->audition->hasFlag('seats_published')) {
throw new ScoreEntryException('Cannot score an entry in an audition with published seats');
}
if ($entry->audition->hasFlag('advancement_published')) {
throw new ScoreEntryException('Cannot score an entry in an audition with published advancement');
}
$requiredScores = $entry->audition->scoringGuide->subscores()->count();
if ($scores->count() !== $requiredScores) {
throw new ScoreEntryException('Invalid number of scores');
}
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Exceptions;
use Exception;
class ScoreEntryException extends Exception
{
//
}

View File

@ -3,27 +3,23 @@
namespace App\Services; namespace App\Services;
use App\Models\Entry; use App\Models\Entry;
use App\Models\ScoreSheet;
use App\Models\ScoringGuide;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use function array_unshift;
class ScoreService class ScoreService
{ {
protected $auditionCache;
protected $entryCache;
/** /**
* Create a new class instance. * Create a new class instance.
*/ */
public function __construct(AuditionService $auditionCache, EntryService $entryCache) public function __construct()
{ {
$this->auditionCache = $auditionCache;
$this->entryCache = $entryCache; }
public function isEntryFullyScored(Entry $entry): bool
{
$requiredJudges = $entry->audition->judges()->count();
$scoreSheets = $entry->scoreSheets()->count();
return $requiredJudges === $scoreSheets;
} }
} }

View File

@ -1,5 +1,9 @@
<?php <?php
use App\Actions\EnterScore;
use App\Exceptions\ScoreEntryException;
use App\Models\Entry;
use App\Models\User;
use App\Settings; use App\Settings;
function tw_max_width_class_array(): array function tw_max_width_class_array(): array
@ -25,7 +29,16 @@ function tw_max_width_class_array(): array
return $return; return $return;
} }
function auditionSetting($key)
function auditionSetting($key) { {
return Settings::get($key); return Settings::get($key);
} }
/**
* @throws ScoreEntryException
*/
function enterScore(User $user, Entry $entry, array $scores): \App\Models\ScoreSheet
{
$scoreEntry = new EnterScore();
return $scoreEntry($user, $entry, $scores);
}

View File

@ -0,0 +1,84 @@
<?php
namespace Database\Seeders;
use App\Models\Audition;
use App\Models\Room;
use App\Models\ScoringGuide;
use App\Models\SubscoreDefinition;
use Illuminate\Database\Seeder;
class AuditionWithScoringGuideAndRoom extends Seeder
{
/**
* Run the database seeds.
*/
// TiebreakOrder: Tone, Sightreading, Etude 1, Etude 2, Scale
// Scale is only for seating, Tone is only for advancement
public function run(): void
{
$room = Room::factory()->create(['id' => 1000]);
$sg = ScoringGuide::factory()->create(['id' => 1000]);
SubscoreDefinition::create([
'id' => 1001,
'scoring_guide_id' => $sg->id,
'name' => 'Scale',
'max_score' => 100,
'weight' => 1,
'display_order' => 1,
'tiebreak_order' => 5,
'for_seating' => 1,
'for_advance' => 0,
]);
SubscoreDefinition::create([
'id' => 1002,
'scoring_guide_id' => $sg->id,
'name' => 'Etude 1',
'max_score' => 100,
'weight' => 2,
'display_order' => 2,
'tiebreak_order' => 3,
'for_seating' => 1,
'for_advance' => 1,
]);
SubscoreDefinition::create([
'id' => 1003,
'scoring_guide_id' => $sg->id,
'name' => 'Etude 2',
'max_score' => 100,
'weight' => 2,
'display_order' => 3,
'tiebreak_order' => 4,
'for_seating' => 1,
'for_advance' => 1,
]);
SubscoreDefinition::create([
'id' => 1004,
'scoring_guide_id' => $sg->id,
'name' => 'Sight Reading',
'max_score' => 100,
'weight' => 3,
'display_order' => 4,
'tiebreak_order' => 2,
'for_seating' => 1,
'for_advance' => 1,
]);
SubscoreDefinition::create([
'id' => 1005,
'scoring_guide_id' => $sg->id,
'name' => 'Tone',
'max_score' => 100,
'weight' => 1,
'display_order' => 5,
'tiebreak_order' => 1,
'for_seating' => 0,
'for_advance' => 1,
]);
Audition::factory()->create([
'id' => 1000,
'room_id' => $room->id,
'scoring_guide_id' => $sg->id,
'name' => 'Test Audition',
]);
}
}

View File

@ -1,17 +0,0 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class RoomSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
//
}
}

View File

@ -1,17 +0,0 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class SchoolSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
//
}
}

View File

@ -1,17 +0,0 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class ScoreSheetSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
//
}
}

View File

@ -1,17 +0,0 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class ScoringGuideSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
//
}
}

View File

@ -1,17 +0,0 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class StudentSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
//
}
}

View File

@ -1,17 +0,0 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class SubscoreDefinitionSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
//
}
}

View File

@ -22,7 +22,7 @@
<div class="w-56 shrink rounded-xl bg-white p-4 text-sm font-semibold leading-6 text-gray-900 shadow-lg ring-1 ring-gray-900/5"> <div class="w-56 shrink rounded-xl bg-white p-4 text-sm font-semibold leading-6 text-gray-900 shadow-lg ring-1 ring-gray-900/5">
<a href="{{ route('scores.chooseEntry') }}" class="block p-2 hover:text-indigo-600">Enter Scores</a> <a href="{{ route('scores.chooseEntry') }}" class="block p-2 hover:text-indigo-600">Enter Scores</a>
<a href="{{ route('entry-flags.noShowSelect') }}" class="block p-2 hover:text-indigo-600">Enter No-Shows</a> <a href="{{ route('entry-flags.noShowSelect') }}" class="block p-2 hover:text-indigo-600">Enter No-Shows</a>
<a href="{{ route('tabulation.status') }}" class="block p-2 hover:text-indigo-600">Audition Status</a> <a href="{{ route('seating.status') }}" class="block p-2 hover:text-indigo-600">Audition Status</a>
<a href="{{ route('advancement.status') }}" class="block p-2 hover:text-indigo-600">{{ auditionSetting('advanceTo') }} Status</a> <a href="{{ route('advancement.status') }}" class="block p-2 hover:text-indigo-600">{{ auditionSetting('advanceTo') }} Status</a>
</div> </div>

View File

@ -1,4 +1,4 @@
@php use App\Enums\AuditionFlags;use App\Models\Audition;use App\Models\AuditionFlag; @endphp @php use App\Enums\AuditionFlags;use App\Models\Audition;use App\Models\AuditionFlag;use App\Models\Entry;use App\Models\User; @endphp
@php @endphp @php @endphp
@inject('scoreservice','App\Services\ScoreService'); @inject('scoreservice','App\Services\ScoreService');
@inject('auditionService','App\Services\AuditionService'); @inject('auditionService','App\Services\AuditionService');
@ -8,8 +8,17 @@
<x-layout.app> <x-layout.app>
<x-slot:page_title>Test Page</x-slot:page_title> <x-slot:page_title>Test Page</x-slot:page_title>
@php @php
$audition = Audition::first(); $entry = Entry::find(1127);
$audition->addFlag('drawn'); $judge = User::find(65);
$scoreArray = [
1 => 50,
2 => 60,
3 => 70,
4 => 80,
5 => 90,
];
enterScore($judge, $entry, $scoreArray);
dump($entry->audition->name);
@endphp @endphp

View File

@ -0,0 +1,185 @@
<?php
/** @noinspection PhpUnhandledExceptionInspection */
use App\Actions\EnterScore;
use App\Exceptions\ScoreEntryException;
use App\Models\Entry;
use App\Models\Room;
use App\Models\ScoreSheet;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->scoreEntry = new EnterScore();
});
test('throws an exception if the user does not exist', function () {
$user = User::factory()->make();
$entry = Entry::factory()->create();
enterScore($user, $entry, []);
})->throws(ScoreEntryException::class, 'User does not exist');
test('throws an exception if the entry does not exist', function () {
$user = User::factory()->create();
$entry = Entry::factory()->make();
enterScore($user, $entry, []);
})->throws(ScoreEntryException::class, 'Entry does not exist');
it('throws an exception if the seats for the entries audition are published', function () {
// Arrange
$user = User::factory()->create();
$entry = Entry::factory()->create();
$entry->audition->addFlag('seats_published');
// Act & Assert
enterScore($user, $entry, []);
})->throws(ScoreEntryException::class, 'Cannot score an entry in an audition with published seats');
it('throws an exception if the advancement for the entries audition is published', function () {
// Arrange
$user = User::factory()->create();
$entry = Entry::factory()->create();
$entry->audition->addFlag('advancement_published');
// Act & Assert
enterScore($user, $entry, []);
})->throws(ScoreEntryException::class, 'Cannot score an entry in an audition with published advancement');
it('throws an exception if too many scores are submitted', function () {
// Arrange
loadSampleAudition();
$judge = User::factory()->create();
$entry = Entry::factory()->create(['audition_id' => 1000]);
Room::find(1000)->addJudge($judge);
// Act & Assert
enterScore($judge, $entry, [1, 2, 5, 3, 2, 1]);
})->throws(ScoreEntryException::class, 'Invalid number of scores');
it('throws an exception if too few scores are submitted', function () {
// Arrange
loadSampleAudition();
$judge = User::factory()->create();
$entry = Entry::factory()->create(['audition_id' => 1000]);
Room::find(1000)->addJudge($judge);
// Act & Assert
enterScore($judge, $entry, [1, 2, 5, 3]);
})->throws(ScoreEntryException::class, 'Invalid number of scores');
it('throws an exception if the user is not assigned to judge the entry', function () {
// Arrange
loadSampleAudition();
$judge = User::factory()->create();
$entry = Entry::factory()->create(['audition_id' => 1000]);
// Act & Assert
enterScore($judge, $entry, [1, 2, 5, 3, 7]);
})->throws(ScoreEntryException::class, 'This judge is not assigned to judge this entry');
it('throws an exception if an invalid subscore is provided', function () {
loadSampleAudition();
$judge = User::factory()->create();
$entry = Entry::factory()->create(['audition_id' => 1000]);
Room::find(1000)->addJudge($judge);
$scores = [
1001 => 98,
1002 => 90,
1003 => 87,
1004 => 78,
1006 => 88,
];
enterScore($judge, $entry, $scores);
})->throws(ScoreEntryException::class, 'Invalid Score Submission');
it('throws an exception if a submitted subscore exceeds the maximum allowed', function () {
loadSampleAudition();
$judge = User::factory()->create();
$entry = Entry::factory()->create(['audition_id' => 1000]);
Room::find(1000)->addJudge($judge);
$scores = [
1001 => 98,
1002 => 90,
1003 => 87,
1004 => 78,
1005 => 101,
];
enterScore($judge, $entry, $scores);
})->throws(ScoreEntryException::class, 'Supplied subscore exceeds maximum allowed');
it('removes an existing no_show flag from the entry if one exists', function () {
// Arrange
loadSampleAudition();
$judge = User::factory()->create();
$entry = Entry::factory()->create(['audition_id' => 1000]);
$entry->addFlag('no_show');
Room::find(1000)->addJudge($judge);
$scores = [
1001 => 98,
1002 => 90,
1003 => 87,
1004 => 78,
1005 => 98,
];
// Act
enterScore($judge, $entry, $scores);
// Assert
expect($entry->hasFlag('no_show'))->toBeFalse();
});
it('saves the score with a properly formatted subscore object', function () {
// Arrange
// Arrange
loadSampleAudition();
$judge = User::factory()->create();
$entry = Entry::factory()->create(['audition_id' => 1000]);
$entry->addFlag('no_show');
Room::find(1000)->addJudge($judge);
$scores = [
1001 => 98,
1002 => 90,
1003 => 87,
1004 => 78,
1005 => 98,
];
$desiredReturn = [
1001 => [
'score' => 98,
'subscore_id' => 1001,
'subscore_name' => 'Scale',
],
1002 => [
'score' => 90,
'subscore_id' => 1002,
'subscore_name' => 'Etude 1',
],
1003 => [
'score' => 87,
'subscore_id' => 1003,
'subscore_name' => 'Etude 2',
],
1004 => [
'score' => 78,
'subscore_id' => 1004,
'subscore_name' => 'Sight Reading',
],
1005 => [
'score' => 98,
'subscore_id' => 1005,
'subscore_name' => 'Tone',
],
];
// Act
$newScore = enterScore($judge, $entry, $scores);
// Assert
$checkScoreSheet = ScoreSheet::find($newScore->id);
expect($checkScoreSheet->exists())->toBeTrue()
->and($checkScoreSheet->subscores)->toBe($desiredReturn);
});
it('throws an exception of the entry already has a score by the judge', function() {
// Arrange
loadSampleAudition();
$judge = User::factory()->create();
$entry = Entry::factory()->create(['audition_id' => 1000]);
$entry->addFlag('no_show');
Room::find(1000)->addJudge($judge);
$scores = [
1001 => 98,
1002 => 90,
1003 => 87,
1004 => 78,
1005 => 98,
];
// Act
enterScore($judge, $entry, $scores);
// Assert
enterScore($judge, $entry, $scores);
})->throws(ScoreEntryException::class, 'That judge has already entered scores for that entry');

View File

@ -0,0 +1,44 @@
<?php
use App\Models\Audition;
use App\Models\Entry;
use App\Models\Room;
use App\Models\ScoreSheet;
use App\Models\User;
use App\Services\ScoreService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use function Pest\Laravel\artisan;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->scoreService = new ScoreService();
});
it('can record a score', function () {
// Arrange
// run the seeder AuditionWithScoringGuideAndRoom
artisan('db:seed', ['--class' => 'AuditionWithScoringGuideAndRoom']);
// Act & Assert
expect(Audition::find(1000)->name)->toBe('Test Audition');
});
it('can check if an entry is fully scored', function () {
$room = Room::factory()->create();
$judges = User::factory()->count(2)->create();
$judges->each(fn ($judge) => $room->addJudge($judge));
$audition = Audition::factory()->create(['room_id' => $room->id]);
$entry = Entry::factory()->create(['audition_id' => $audition->id]);
expect($this->scoreService->isEntryFullyScored($entry))->toBeFalse();
ScoreSheet::create([
'user_id' => $judges->first()->id,
'entry_id' => $entry->id,
'subscores' => 7,
]);
expect($this->scoreService->isEntryFullyScored($entry))->toBeFalse();
ScoreSheet::create([
'user_id' => $judges->last()->id,
'entry_id' => $entry->id,
'subscores' => 7,
]);
expect($this->scoreService->isEntryFullyScored($entry))->toBeTrue();
});

View File

@ -15,6 +15,7 @@ use App\Models\User;
use App\Settings; use App\Settings;
use Illuminate\Foundation\Testing\TestCase; use Illuminate\Foundation\Testing\TestCase;
use function Pest\Laravel\actingAs; use function Pest\Laravel\actingAs;
use function Pest\Laravel\artisan;
uses( uses(
Tests\TestCase::class, Tests\TestCase::class,
@ -59,6 +60,10 @@ function actAsNormal()
{ {
actingAs(User::factory()->create()); actingAs(User::factory()->create());
} }
function loadSampleAudition()
{
artisan('db:seed', ['--class' => 'AuditionWithScoringGuideAndRoom']);
}
uses()->beforeEach(function () { uses()->beforeEach(function () {
Settings::set('auditionName', 'Somewhere Band Directors Association'); Settings::set('auditionName', 'Somewhere Band Directors Association');