118 خطوط
6.0 KiB
PHP
118 خطوط
6.0 KiB
PHP
<?php
|
|
|
|
namespace App\Modules\Assessments\Application;
|
|
|
|
use Illuminate\Support\Facades\Validator;
|
|
use Illuminate\Validation\Rule;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
final class QuestionSchema
|
|
{
|
|
/** @return list<string> */
|
|
public function types(): array
|
|
{
|
|
return ['single_choice', 'multiple_choice', 'true_false', 'matching', 'sorting', 'drag_drop', 'hotspot', 'scenario', 'branching_scenario'];
|
|
}
|
|
|
|
/** @param array<string, mixed> $configuration @return array<string, mixed> */
|
|
public function validate(string $type, array $configuration): array
|
|
{
|
|
Validator::make(['type' => $type], ['type' => ['required', Rule::in($this->types())]])->validate();
|
|
$rules = match ($type) {
|
|
'single_choice', 'multiple_choice' => [
|
|
'options' => ['required', 'array', 'min:2', 'max:12'],
|
|
'options.*.id' => ['required', 'string', 'distinct', 'max:80'],
|
|
'options.*.text' => ['required', 'string', 'max:1000'],
|
|
'options.*.correct' => ['required', 'boolean'],
|
|
'options.*.feedback' => ['nullable', 'string', 'max:2000'],
|
|
],
|
|
'true_false' => ['answer' => ['required', 'boolean'], 'feedback' => ['nullable', 'string', 'max:2000']],
|
|
'matching' => [
|
|
'pairs' => ['required', 'array', 'min:2', 'max:12'],
|
|
'pairs.*.left' => ['required', 'string', 'max:500'],
|
|
'pairs.*.right' => ['required', 'string', 'max:500'],
|
|
],
|
|
'sorting' => ['items' => ['required', 'array', 'min:2', 'max:12'], 'items.*' => ['required', 'string', 'distinct', 'max:500']],
|
|
'drag_drop' => [
|
|
'items' => ['required', 'array', 'min:2', 'max:16'],
|
|
'items.*.text' => ['required', 'string', 'max:500'],
|
|
'items.*.target' => ['required', 'string', 'max:160'],
|
|
],
|
|
'hotspot' => [
|
|
'imageAssetId' => ['required', 'string'],
|
|
'hotspots' => ['required', 'array', 'min:1', 'max:12'],
|
|
'hotspots.*.x' => ['required', 'numeric', 'between:0,100'],
|
|
'hotspots.*.y' => ['required', 'numeric', 'between:0,100'],
|
|
'hotspots.*.radius' => ['required', 'numeric', 'between:1,40'],
|
|
'hotspots.*.label' => ['required', 'string', 'max:160'],
|
|
'hotspots.*.correct' => ['required', 'boolean'],
|
|
],
|
|
'scenario' => [
|
|
'context' => ['required', 'string', 'max:5000'],
|
|
'choices' => ['required', 'array', 'min:2', 'max:8'],
|
|
'choices.*.text' => ['required', 'string', 'max:1000'],
|
|
'choices.*.score' => ['required', 'numeric', 'between:0,1'],
|
|
'choices.*.feedback' => ['required', 'string', 'max:3000'],
|
|
],
|
|
'branching_scenario' => [
|
|
'startNodeId' => ['required', 'string'],
|
|
'nodes' => ['required', 'array', 'min:2', 'max:80'],
|
|
'nodes.*.id' => ['required', 'string', 'distinct', 'max:80'],
|
|
'nodes.*.type' => ['required', Rule::in(['scene', 'question', 'result'])],
|
|
'nodes.*.title' => ['required', 'string', 'max:240'],
|
|
'nodes.*.body' => ['nullable', 'string', 'max:5000'],
|
|
'nodes.*.choices' => ['nullable', 'array', 'max:12'],
|
|
'nodes.*.choices.*.text' => ['required_with:nodes.*.choices', 'string', 'max:1000'],
|
|
'nodes.*.choices.*.targetNodeId' => ['required_with:nodes.*.choices', 'string'],
|
|
],
|
|
};
|
|
$validated = Validator::make($configuration, $rules)->validate();
|
|
if (in_array($type, ['single_choice', 'multiple_choice'], true)) {
|
|
$correct = collect($validated['options'])->where('correct', true)->count();
|
|
if (($type === 'single_choice' && $correct !== 1) || ($type === 'multiple_choice' && $correct < 1)) {
|
|
throw ValidationException::withMessages(['configuration.options' => [$type === 'single_choice' ? 'دقیقاً یک گزینه باید صحیح باشد.' : 'حداقل یک گزینه باید صحیح باشد.']]);
|
|
}
|
|
}
|
|
if ($type === 'branching_scenario') {
|
|
$this->validateGraph($validated);
|
|
}
|
|
|
|
return $validated;
|
|
}
|
|
|
|
/** @param array<string, mixed> $graph */
|
|
private function validateGraph(array $graph): void
|
|
{
|
|
$nodes = collect($graph['nodes'])->keyBy('id');
|
|
if (! $nodes->has($graph['startNodeId'])) {
|
|
throw ValidationException::withMessages(['configuration.startNodeId' => ['گره شروع وجود ندارد.']]);
|
|
}
|
|
foreach ($nodes as $node) {
|
|
foreach ($node['choices'] ?? [] as $choice) {
|
|
if (! $nodes->has($choice['targetNodeId'])) {
|
|
throw ValidationException::withMessages(['configuration.nodes' => ["مسیر به گره ناموجود {$choice['targetNodeId']} اشاره میکند."]]);
|
|
}
|
|
}
|
|
}
|
|
$reachable = [];
|
|
$queue = [$graph['startNodeId']];
|
|
while ($queue !== []) {
|
|
$id = array_shift($queue);
|
|
if (isset($reachable[$id])) {
|
|
continue;
|
|
}
|
|
$reachable[$id] = true;
|
|
foreach (($nodes[$id]['choices'] ?? []) as $choice) {
|
|
$queue[] = $choice['targetNodeId'];
|
|
}
|
|
}
|
|
$unreachable = $nodes->keys()->reject(fn (string $id): bool => isset($reachable[$id]))->values();
|
|
if ($unreachable->isNotEmpty()) {
|
|
throw ValidationException::withMessages(['configuration.nodes' => ['گرههای غیرقابلدسترسی: '.$unreachable->implode('، ')]]);
|
|
}
|
|
if (! $nodes->contains(fn (array $node): bool => $node['type'] === 'result')) {
|
|
throw ValidationException::withMessages(['configuration.nodes' => ['سناریو باید حداقل یک گره نتیجه داشته باشد.']]);
|
|
}
|
|
}
|
|
}
|