From 43b983d02f4a83ec8ca66c321076aa31a065a918 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 29 Sep 2026 23:57:56 +0200 Subject: [PATCH 1/3] feat(timetable): the generator input from learniq's event or two uploaded sheets --- appinfo/routes.php | 2 + lib/Controller/TimetableInputController.php | 109 ++++++++ lib/Event/TimetableActivitiesQueryEvent.php | 148 ++++++++++ lib/Service/TimetableCsvParser.php | 264 ++++++++++++++++++ lib/Service/TimetableInputBuilder.php | 202 ++++++++++++++ lib/Timetabling/SolverInput.php | 85 ++++++ .../changes/timetabling-generator/tasks.md | 6 +- .../TimetableInputControllerTest.php | 146 ++++++++++ tests/unit/Service/TimetableCsvParserTest.php | 120 ++++++++ .../Service/TimetableInputBuilderTest.php | 183 ++++++++++++ 10 files changed, 1262 insertions(+), 3 deletions(-) create mode 100644 lib/Controller/TimetableInputController.php create mode 100644 lib/Event/TimetableActivitiesQueryEvent.php create mode 100644 lib/Service/TimetableCsvParser.php create mode 100644 lib/Service/TimetableInputBuilder.php create mode 100644 lib/Timetabling/SolverInput.php create mode 100644 tests/unit/Controller/TimetableInputControllerTest.php create mode 100644 tests/unit/Service/TimetableCsvParserTest.php create mode 100644 tests/unit/Service/TimetableInputBuilderTest.php diff --git a/appinfo/routes.php b/appinfo/routes.php index 850e9e71..7303e607 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -53,4 +53,6 @@ ['name' => 'timetable#upsert', 'url' => '/api/timetable/sessions/upsert', 'verb' => 'POST'], // Admins publish a source's draft lessons in a window (timetable-draft-review). ['name' => 'timetable#publish', 'url' => '/api/timetable/sessions/publish', 'verb' => 'POST'], + // Admins upload the activities and rooms sheets for the timetable generator (timetabling-generator 2.2). + ['name' => 'timetableInput#upload', 'url' => '/api/timetable/input/upload', 'verb' => 'POST'], ]); diff --git a/lib/Controller/TimetableInputController.php b/lib/Controller/TimetableInputController.php new file mode 100644 index 00000000..cff85fe1 --- /dev/null +++ b/lib/Controller/TimetableInputController.php @@ -0,0 +1,109 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Controller; + +use OCA\Planninq\AppInfo\Application; +use OCA\Planninq\Service\SettingsService; +use OCA\Planninq\Service\TimetableCsvParser; +use OCA\Planninq\Service\TimetableInputBuilder; +use OCA\Planninq\Settings\AdminSettings; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; + +/** + * Takes the uploaded rooms and activities sheets. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ +class TimetableInputController extends Controller { + + /** + * Constructor. + * + * @param IRequest $request The request. + * @param TimetableCsvParser $parser Reads the sheets. + * @param TimetableInputBuilder $inputBuilder Keeps the parsed sheets. + * @param SettingsService $settingsService Answers whether the caller is an admin. + * + * @return void + */ + public function __construct( + IRequest $request, + private readonly TimetableCsvParser $parser, + private readonly TimetableInputBuilder $inputBuilder, + private readonly SettingsService $settingsService, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Parse and keep the rooms and activities sheets. Admins only. + * + * Nextcloud's admin middleware refuses everyone else before this runs; the + * explicit check is defence in depth, as in TimetableController::upsert(). + * Nothing is kept when either sheet has a refused line. + * + * @return JSONResponse 200 with the counts; 400 with the refused lines; 403 for a non-admin. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + #[AuthorizedAdminSetting(settings: AdminSettings::class)] + public function upload(): JSONResponse { + if ($this->settingsService->isCurrentUserAdmin() === false) { + return new JSONResponse(['error' => 'Only an admin can upload timetable activities.'], Http::STATUS_FORBIDDEN); + } + + $roomsCsv = $this->request->getParam('rooms'); + $activitiesCsv = $this->request->getParam('activities'); + if (is_string($roomsCsv) === false || is_string($activitiesCsv) === false) { + return new JSONResponse(['error' => 'Send the rooms and the activities sheet as text.'], Http::STATUS_BAD_REQUEST); + } + + $rooms = $this->parser->parseRooms(csv: $roomsCsv); + $types = array_values(array_unique(array_column($rooms['rows'], 'type'))); + $activities = $this->parser->parseActivities(csv: $activitiesCsv, roomTypes: $types); + $errors = [ + 'rooms' => $rooms['errors'], + 'activities' => $activities['errors'], + ]; + if ($rooms['errors'] !== [] || $activities['errors'] !== []) { + return new JSONResponse(['error' => 'Some lines were refused. Nothing was kept.', 'errors' => $errors], Http::STATUS_BAD_REQUEST); + } + + $this->inputBuilder->storeUpload(rooms: $rooms['rows'], activities: $activities['rows']); + + return new JSONResponse( + [ + 'rooms' => count($rooms['rows']), + 'activities' => count($activities['rows']), + 'lessons' => array_sum(array_column($activities['rows'], 'lessonsPerWeek')), + ] + ); + }//end upload() +}//end class diff --git a/lib/Event/TimetableActivitiesQueryEvent.php b/lib/Event/TimetableActivitiesQueryEvent.php new file mode 100644 index 00000000..6dac14cc --- /dev/null +++ b/lib/Event/TimetableActivitiesQueryEvent.php @@ -0,0 +1,148 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Event; + +use OCP\EventDispatcher\Event; + +/** + * A request for the activities and rooms of one academic year. + * + * An activity row: group, subject, teacher (a Nextcloud user id), lessonsPerWeek, + * lessonLength (in periods) and roomType. A room row: reference, capacity, type. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ +class TimetableActivitiesQueryEvent extends Event { + + /** + * Contract version of this event and its answer. + * + * @var integer + */ + public const CONTRACT_VERSION = 1; + + /** + * The activities, once an app answered. + * + * @var array>|null + */ + private ?array $activities = null; + + /** + * The rooms, once an app answered. + * + * @var array> + */ + private array $rooms = []; + + /** + * The app that answered. + * + * @var string|null + */ + private ?string $answeredBy = null; + + /** + * Constructor. + * + * @param string $academicYear The academic year asked for, such as `2026-2027`. + * + * @return void + */ + public function __construct( + private readonly string $academicYear, + ) { + parent::__construct(); + }//end __construct() + + /** + * The academic year asked for. + * + * @return string + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + public function getAcademicYear(): string { + return $this->academicYear; + }//end getAcademicYear() + + /** + * Answer the event with the year's activities and rooms. + * + * @param string $app The answering app, such as `learniq`. + * @param array> $activities Activity rows. + * @param array> $rooms Room rows. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + public function answer(string $app, array $activities, array $rooms): void { + $this->answeredBy = $app; + $this->activities = array_values($activities); + $this->rooms = array_values($rooms); + }//end answer() + + /** + * The activities, or null when no app answered. + * + * @return array>|null + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + public function getActivities(): ?array { + return $this->activities; + }//end getActivities() + + /** + * The rooms the answering app sent. + * + * @return array> + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + public function getRooms(): array { + return $this->rooms; + }//end getRooms() + + /** + * The app that answered, or null. + * + * @return string|null + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + public function getAnsweredBy(): ?string { + return $this->answeredBy; + }//end getAnsweredBy() +}//end class diff --git a/lib/Service/TimetableCsvParser.php b/lib/Service/TimetableCsvParser.php new file mode 100644 index 00000000..f2ce499f --- /dev/null +++ b/lib/Service/TimetableCsvParser.php @@ -0,0 +1,264 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Service; + +/** + * Parses the rooms and activities sheets into the rows the input builder reads. + * + * A result is {rows, errors}; an error is {line, field, code, message}. Codes: + * `header`, `empty`, `whole-number`, `unknown-room-type`. The header check + * ignores case and surrounding spaces; a comma or a semicolon separates columns. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ +class TimetableCsvParser { + + /** + * The rooms sheet columns, in order. + * + * @var array + */ + public const ROOM_COLUMNS = ['reference', 'capacity', 'type']; + + /** + * The activities sheet columns, in order. + * + * @var array + */ + public const ACTIVITY_COLUMNS = ['group', 'subject', 'teacher', 'lessons per week', 'lesson length', 'room type']; + + /** + * The row key each activities column is stored under. + * + * @var array + */ + private const ACTIVITY_KEYS = [ + 'group' => 'group', + 'subject' => 'subject', + 'teacher' => 'teacher', + 'lessons per week' => 'lessonsPerWeek', + 'lesson length' => 'lessonLength', + 'room type' => 'roomType', + ]; + + /** + * Parse the rooms sheet. + * + * @param string $csv The sheet as text. + * + * @return array{rows:array,errors:array} + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + public function parseRooms(string $csv): array { + $rows = []; + $errors = []; + foreach ($this->records(csv: $csv, columns: self::ROOM_COLUMNS, errors: $errors) as $line => $cells) { + $capacity = $this->wholeNumber(value: $cells['capacity'], line: $line, field: 'capacity', errors: $errors); + if ($this->filled(cells: $cells, line: $line, errors: $errors) === false || $capacity === null) { + continue; + } + + $rows[] = ['reference' => $cells['reference'], 'capacity' => $capacity, 'type' => $cells['type']]; + } + + return ['rows' => $rows, 'errors' => $errors]; + }//end parseRooms() + + /** + * Parse the activities sheet; every room type must be one of the rooms' types. + * + * @param string $csv The sheet as text. + * @param array $roomTypes The room types the rooms sheet holds. + * + * @return array{rows:array>,errors:array} + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + public function parseActivities(string $csv, array $roomTypes): array { + $rows = []; + $errors = []; + foreach ($this->records(csv: $csv, columns: self::ACTIVITY_COLUMNS, errors: $errors) as $line => $cells) { + $lessons = $this->wholeNumber(value: $cells['lessons per week'], line: $line, field: 'lessons per week', errors: $errors); + $length = $this->wholeNumber(value: $cells['lesson length'], line: $line, field: 'lesson length', errors: $errors); + $filled = $this->filled(cells: $cells, line: $line, errors: $errors); + $known = $this->knownRoomType(type: $cells['room type'], roomTypes: $roomTypes, line: $line, errors: $errors); + if ($filled === false || $known === false || $lessons === null || $length === null) { + continue; + } + + $row = []; + foreach (self::ACTIVITY_KEYS as $column => $key) { + $row[$key] = $cells[$column]; + } + + $row['lessonsPerWeek'] = $lessons; + $row['lessonLength'] = $length; + $rows[] = $row; + } + + return ['rows' => $rows, 'errors' => $errors]; + }//end parseActivities() + + /** + * The data lines by line number, cells keyed by column; a wrong header adds one error and yields nothing. + * + * @param string $csv The sheet. + * @param array $columns The expected columns. + * @param array $errors Errors, appended to. + * + * @return array> + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + private function records(string $csv, array $columns, array &$errors): array { + $lines = preg_split('/\r\n|\r|\n/', trim($csv)); + if ($lines === false) { + $lines = []; + } + + $head = array_map(static fn (string $cell): string => mb_strtolower(trim($cell)), $this->cells(line: (string)($lines[0] ?? ''))); + if ($head !== $columns) { + $errors[] = $this->error(line: 1, field: '', code: 'header', message: 'The first line must be: '.implode(', ', $columns).'.'); + return []; + } + + $records = []; + foreach (array_slice($lines, 1, null, true) as $index => $line) { + if (trim($line) === '') { + continue; + } + + $cells = array_pad(array_map('trim', $this->cells(line: $line)), count($columns), ''); + $records[$index + 1] = array_combine($columns, array_slice($cells, 0, count($columns))); + } + + return $records; + }//end records() + + /** + * The cells of one line, split on the separator the line uses. + * + * @param string $line One line. + * + * @return array + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + private function cells(string $line): array { + $separator = ','; + if (substr_count($line, ';') > substr_count($line, ',')) { + $separator = ';'; + } + + return array_map('strval', str_getcsv($line, $separator, '"', '')); + }//end cells() + + /** + * Whether no cell is empty; each empty cell adds an error. + * + * @param array $cells The line's cells. + * @param int $line The line number. + * @param array $errors Errors, appended to. + * + * @return bool + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + private function filled(array $cells, int $line, array &$errors): bool { + $filled = true; + foreach ($cells as $field => $value) { + if ($value === '') { + $errors[] = $this->error(line: $line, field: $field, code: 'empty', message: "Line {$line}: {$field} is empty."); + $filled = false; + } + } + + return $filled; + }//end filled() + + /** + * A whole number of 1 or more, or null with an error; an empty cell is left to filled(). + * + * @param string $value The cell. + * @param int $line The line number. + * @param string $field The column. + * @param array $errors Errors, appended to. + * + * @return int|null + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + private function wholeNumber(string $value, int $line, string $field, array &$errors): ?int { + if (preg_match('/^\d+$/', $value) === 1 && (int)$value >= 1) { + return (int)$value; + } + + if ($value !== '') { + $errors[] = $this->error(line: $line, field: $field, code: 'whole-number', message: "Line {$line}: {$field} must be a whole number of 1 or more."); + } + + return null; + }//end wholeNumber() + + /** + * Whether a room type is one of the rooms' types; an unknown one adds an error. + * + * @param string $type The cell. + * @param array $roomTypes The known types. + * @param int $line The line number. + * @param array $errors Errors, appended to. + * + * @return bool + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + private function knownRoomType(string $type, array $roomTypes, int $line, array &$errors): bool { + if ($type === '' || in_array($type, $roomTypes, true) === true) { + return true; + } + + $errors[] = $this->error(line: $line, field: 'room type', code: 'unknown-room-type', message: "Line {$line}: room type {$type} is not on the rooms sheet."); + return false; + }//end knownRoomType() + + /** + * One refusal. + * + * @param int $line The line number. + * @param string $field The column, empty for the header. + * @param string $code The refusal code. + * @param string $message A readable refusal. + * + * @return array{line:int,field:string,code:string,message:string} + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + private function error(int $line, string $field, string $code, string $message): array { + return ['line' => $line, 'field' => $field, 'code' => $code, 'message' => $message]; + }//end error() +}//end class diff --git a/lib/Service/TimetableInputBuilder.php b/lib/Service/TimetableInputBuilder.php new file mode 100644 index 00000000..5526c9b3 --- /dev/null +++ b/lib/Service/TimetableInputBuilder.php @@ -0,0 +1,202 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Service; + +use OCA\Planninq\AppInfo\Application; +use OCA\Planninq\Event\TimetableActivitiesQueryEvent; +use OCA\Planninq\Timetabling\SolverInput; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IAppConfig; + +/** + * Builds the SolverInput of a generator run. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ +class TimetableInputBuilder { + + /** + * App config key holding the uploaded rooms sheet, parsed. + * + * @var string + */ + public const CSV_ROOMS_KEY = 'timetable_csv_rooms'; + + /** + * App config key holding the uploaded activities sheet, parsed. + * + * @var string + */ + public const CSV_ACTIVITIES_KEY = 'timetable_csv_activities'; + + /** + * Why an input is empty when nothing supplied activities. + * + * @var string + */ + public const NO_ACTIVITIES = 'No activities: learniq did not answer and no CSV was uploaded'; + + /** + * The wish fields the solver reads. + * + * @var array + */ + private const WISH_FIELDS = ['appliesTo', 'reference', 'kind', 'periods', 'limit', 'strength', 'weight']; + + /** + * Constructor. + * + * @param IEventDispatcher $dispatcher Dispatches the activities query. + * @param IAppConfig $appConfig Holds the uploaded sheets. + * @param TimetableGridService $grid The week grid. + * + * @return void + */ + public function __construct( + private readonly IEventDispatcher $dispatcher, + private readonly IAppConfig $appConfig, + private readonly TimetableGridService $grid, + ) { + }//end __construct() + + /** + * The input of a run for one academic year and the given wishes. + * + * @param string $academicYear The academic year, such as `2026-2027`. + * @param array> $wishes timetableWish objects. + * + * @return SolverInput + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + public function build(string $academicYear, array $wishes): SolverInput { + $periods = $this->grid->periodKeys(); + $wishes = array_map(fn (array $wish): array => $this->wish(wish: $wish), array_values($wishes)); + + $event = new TimetableActivitiesQueryEvent(academicYear: $academicYear); + $this->dispatcher->dispatchTyped($event); + + $activities = ($event->getActivities() ?? []); + $rooms = $event->getRooms(); + $source = (string)$event->getAnsweredBy(); + if ($activities === []) { + $activities = $this->stored(key: self::CSV_ACTIVITIES_KEY); + $rooms = $this->stored(key: self::CSV_ROOMS_KEY); + $source = 'csv'; + } + + if ($activities === []) { + return new SolverInput(periods: $periods, rooms: [], lessons: [], wishes: $wishes, source: 'none', reason: self::NO_ACTIVITIES); + } + + return new SolverInput(periods: $periods, rooms: array_values($rooms), lessons: $this->lessons(activities: $activities), wishes: $wishes, source: $source); + }//end build() + + /** + * Keep an uploaded rooms and activities sheet as the input for runs no app answers. + * + * @param array> $rooms Parsed room rows. + * @param array> $activities Parsed activity rows. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + public function storeUpload(array $rooms, array $activities): void { + $this->appConfig->setValueString(Application::APP_ID, self::CSV_ROOMS_KEY, (string)json_encode(array_values($rooms)), lazy: true); + $this->appConfig->setValueString(Application::APP_ID, self::CSV_ACTIVITIES_KEY, (string)json_encode(array_values($activities)), lazy: true); + }//end storeUpload() + + /** + * One lesson per weekly lesson of each activity, keyed `{group}:{subject}:{n}`. + * + * @param array> $activities Activity rows. + * + * @return array> + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + private function lessons(array $activities): array { + $lessons = []; + foreach ($activities as $activity) { + $key = ((string)($activity['group'] ?? '')).':'.((string)($activity['subject'] ?? '')); + $count = max(0, (int)($activity['lessonsPerWeek'] ?? 0)); + for ($number = 1; $number <= $count; $number++) { + $lessons[] = [ + 'key' => $key.':'.$number, + 'activity' => $key, + 'group' => (string)($activity['group'] ?? ''), + 'subject' => (string)($activity['subject'] ?? ''), + 'teacher' => (string)($activity['teacher'] ?? ''), + 'roomType' => (string)($activity['roomType'] ?? ''), + 'length' => max(1, (int)($activity['lessonLength'] ?? 1)), + ]; + } + } + + return $lessons; + }//end lessons() + + /** + * A wish as the solver reads it: its id and the fields that constrain. + * + * @param array $wish A timetableWish object. + * + * @return array + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + private function wish(array $wish): array { + $out = ['id' => (string)($wish['id'] ?? ($wish['@self']['id'] ?? ''))]; + foreach (self::WISH_FIELDS as $field) { + if (array_key_exists($field, $wish) === true) { + $out[$field] = $wish[$field]; + } + } + + return $out; + }//end wish() + + /** + * A stored sheet, or an empty list. + * + * @param string $key The app config key. + * + * @return array> + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + private function stored(string $key): array { + $rows = json_decode($this->appConfig->getValueString(Application::APP_ID, $key, '[]', lazy: true), true); + if (is_array($rows) === false) { + return []; + } + + return array_values(array_filter($rows, 'is_array')); + }//end stored() +}//end class diff --git a/lib/Timetabling/SolverInput.php b/lib/Timetabling/SolverInput.php new file mode 100644 index 00000000..acab68ee --- /dev/null +++ b/lib/Timetabling/SolverInput.php @@ -0,0 +1,85 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Timetabling; + +/** + * The input of one generator run. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ +final class SolverInput { + + /** + * Constructor. + * + * @param array $periods Period keys, such as `mon-1`. + * @param array> $rooms Rooms: reference, capacity, type. + * @param array> $lessons Lessons: key, activity, group, subject, teacher, roomType, length. + * @param array> $wishes Wishes: id, appliesTo, reference, kind, periods, limit, strength, weight. + * @param string $source Where the activities came from: an app id, `csv` or `none`. + * @param string|null $reason Why the input is empty, or null. + * + * @return void + */ + public function __construct( + public readonly array $periods, + public readonly array $rooms, + public readonly array $lessons, + public readonly array $wishes, + public readonly string $source, + public readonly ?string $reason=null, + ) { + }//end __construct() + + /** + * Whether there is nothing to place. + * + * @return bool + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + public function isEmpty(): bool { + return $this->lessons === []; + }//end isEmpty() + + /** + * The shape a timetableScenario keeps under `input`. + * + * @return array{periods:array,rooms:array>,lessons:array>,wishes:array>,source:string} + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + public function toArray(): array { + return [ + 'periods' => $this->periods, + 'rooms' => $this->rooms, + 'lessons' => $this->lessons, + 'wishes' => $this->wishes, + 'source' => $this->source, + ]; + }//end toArray() +}//end class diff --git a/openspec/changes/timetabling-generator/tasks.md b/openspec/changes/timetabling-generator/tasks.md index daae39bb..c4383c4e 100644 --- a/openspec/changes/timetabling-generator/tasks.md +++ b/openspec/changes/timetabling-generator/tasks.md @@ -10,9 +10,9 @@ DECISIONS.md row 17). Sections 5 onward assume the solver answer recorded in des ## 2. Activities and the solver input -- [ ] 2.1 `TimetableActivitiesQueryEvent` (typed, ADR-041) and `TimetableInputBuilder` that turns activities, rooms, the grid and the wishes into a `SolverInput`. Verify: PHPUnit on the builder with a fixture; an event nobody answers yields an empty input and the reason "No activities: learniq did not answer and no CSV was uploaded". -- [ ] 2.2 CSV upload of activities and rooms (same columns as the event), admin only. Verify: PHPUnit on the parser (header check, lessons per week and lesson length as whole numbers, unknown room type refused with the line number); controller refuses a non-admin. -- [ ] 2.3 Draft the learniq listener contract to `~/memcap-work/build-all/for-ruben/learniq-timetable-activities-event.md`. Verify: the file names the event class, its fields and the learniq endpoint it mirrors. +- [x] 2.1 `TimetableActivitiesQueryEvent` (typed, ADR-041) and `TimetableInputBuilder` that turns activities, rooms, the grid and the wishes into a `SolverInput`. Verify: PHPUnit on the builder with a fixture; an event nobody answers yields an empty input and the reason "No activities: learniq did not answer and no CSV was uploaded". +- [x] 2.2 CSV upload of activities and rooms (same columns as the event), admin only. Verify: PHPUnit on the parser (header check, lessons per week and lesson length as whole numbers, unknown room type refused with the line number); controller refuses a non-admin. +- [x] 2.3 Draft the learniq listener contract to `~/memcap-work/build-all/for-ruben/learniq-timetable-activities-event.md`. Verify: the file names the event class, its fields and the learniq endpoint it mirrors. ## 3. Wishes diff --git a/tests/unit/Controller/TimetableInputControllerTest.php b/tests/unit/Controller/TimetableInputControllerTest.php new file mode 100644 index 00000000..8f15c62b --- /dev/null +++ b/tests/unit/Controller/TimetableInputControllerTest.php @@ -0,0 +1,146 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Tests\Unit\Controller; + +use OCA\Planninq\Controller\TimetableInputController; +use OCA\Planninq\Service\SettingsService; +use OCA\Planninq\Service\TimetableCsvParser; +use OCA\Planninq\Service\TimetableGridService; +use OCA\Planninq\Service\TimetableInputBuilder; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IAppConfig; +use OCP\IRequest; +use PHPUnit\Framework\TestCase; +use ReflectionMethod; + +/** + * Admins only; nothing is kept when a line is refused. + */ +class TimetableInputControllerTest extends TestCase { + + /** + * The app config values, in memory. + * + * @var array + */ + private array $stored = []; + + /** + * A controller with the real parser and builder, the given body and caller. + * + * @param array $body The request parameters. + * @param bool $admin Whether the caller is an admin. + * + * @return TimetableInputController + */ + private function controller(array $body, bool $admin): TimetableInputController { + $request = $this->createMock(IRequest::class); + $request->method('getParam')->willReturnCallback(static fn (string $key): mixed => ($body[$key] ?? null)); + + $config = $this->createMock(IAppConfig::class); + $config->method('getValueString')->willReturnCallback( + fn (string $app, string $key, string $default = '', bool $lazy = false): string => ($this->stored[$key] ?? $default) + ); + $config->method('setValueString')->willReturnCallback( + function (string $app, string $key, string $value, bool $lazy = false): bool { + $this->stored[$key] = $value; + return true; + } + ); + + $settings = $this->createMock(SettingsService::class); + $settings->method('isCurrentUserAdmin')->willReturn($admin); + + return new TimetableInputController( + request: $request, + parser: new TimetableCsvParser(), + inputBuilder: new TimetableInputBuilder(dispatcher: $this->createMock(IEventDispatcher::class), appConfig: $config, grid: new TimetableGridService(appConfig: $config)), + settingsService: $settings, + ); + }//end controller() + + /** + * The two sheets of the spec's school. + * + * @return array + */ + private function sheets(): array { + return [ + 'rooms' => "reference,capacity,type\nr-101,30,classroom\n", + 'activities' => "group,subject,teacher,lessons per week,lesson length,room type\n3A,English,klaas,3,1,classroom\n3A,Maths,noor,2,2,classroom\n", + ]; + }//end sheets() + + /** + * An admin uploads both sheets: they are kept and counted. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + public function testAnAdminUploadIsKept(): void { + $response = $this->controller(body: $this->sheets(), admin: true)->upload(); + + self::assertSame(expected: Http::STATUS_OK, actual: $response->getStatus()); + self::assertSame(expected: ['rooms' => 1, 'activities' => 2, 'lessons' => 5], actual: $response->getData()); + self::assertCount(expectedCount: 2, haystack: json_decode($this->stored['timetable_csv_activities'], true)); + self::assertSame(expected: 'r-101', actual: json_decode($this->stored['timetable_csv_rooms'], true)[0]['reference']); + }//end testAnAdminUploadIsKept() + + /** + * A non-admin is refused and nothing is kept; the route is admin-only. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + public function testANonAdminIsRefused(): void { + $response = $this->controller(body: $this->sheets(), admin: false)->upload(); + + self::assertSame(expected: Http::STATUS_FORBIDDEN, actual: $response->getStatus()); + self::assertSame(expected: [], actual: $this->stored); + self::assertNotSame(expected: [], actual: (new ReflectionMethod(TimetableInputController::class, 'upload'))->getAttributes(AuthorizedAdminSetting::class)); + }//end testANonAdminIsRefused() + + /** + * A refused line keeps nothing and answers with the lines. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + public function testARefusedLineKeepsNothing(): void { + $body = ['activities' => "group,subject,teacher,lessons per week,lesson length,room type\n3A,Swimming,piet,1,1,pool\n"] + $this->sheets(); + $response = $this->controller(body: $body, admin: true)->upload(); + + self::assertSame(expected: Http::STATUS_BAD_REQUEST, actual: $response->getStatus()); + self::assertSame(expected: 'unknown-room-type', actual: $response->getData()['errors']['activities'][0]['code']); + self::assertSame(expected: 2, actual: $response->getData()['errors']['activities'][0]['line']); + self::assertSame(expected: [], actual: $this->stored); + + $missing = $this->controller(body: ['rooms' => 'x'], admin: true)->upload(); + self::assertSame(expected: Http::STATUS_BAD_REQUEST, actual: $missing->getStatus()); + }//end testARefusedLineKeepsNothing() +}//end class diff --git a/tests/unit/Service/TimetableCsvParserTest.php b/tests/unit/Service/TimetableCsvParserTest.php new file mode 100644 index 00000000..675fdbf0 --- /dev/null +++ b/tests/unit/Service/TimetableCsvParserTest.php @@ -0,0 +1,120 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Tests\Unit\Service; + +use OCA\Planninq\Service\TimetableCsvParser; +use PHPUnit\Framework\TestCase; + +/** + * Header check, whole numbers and room types, each refusal with its line. + */ +class TimetableCsvParserTest extends TestCase { + + /** + * A good sheet: header case and spaces ignored, semicolons accepted, blank lines skipped. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + public function testAGoodSheetBecomesRows(): void { + $parser = new TimetableCsvParser(); + $rooms = $parser->parseRooms(csv: "Reference,Capacity,Type\nr-101,30,classroom\n\nlab-1,16,lab\n"); + self::assertSame(expected: [], actual: $rooms['errors']); + self::assertSame(expected: [['reference' => 'r-101', 'capacity' => 30, 'type' => 'classroom'], ['reference' => 'lab-1', 'capacity' => 16, 'type' => 'lab']], actual: $rooms['rows']); + + $activities = $parser->parseActivities(csv: " Group ; Subject;Teacher;Lessons per week;Lesson length;Room type\r\n3A;Chemistry;noor;2;2;lab\r\n", roomTypes: ['classroom', 'lab']); + self::assertSame(expected: [], actual: $activities['errors']); + self::assertSame( + expected: [['group' => '3A', 'subject' => 'Chemistry', 'teacher' => 'noor', 'lessonsPerWeek' => 2, 'lessonLength' => 2, 'roomType' => 'lab']], + actual: $activities['rows'] + ); + }//end testAGoodSheetBecomesRows() + + /** + * A sheet whose first line is not the header is refused as a whole. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + public function testAWrongHeaderIsRefused(): void { + $result = (new TimetableCsvParser())->parseActivities(csv: "group,subject,teacher,hours\n3A,English,klaas,3\n", roomTypes: ['classroom']); + + self::assertSame(expected: [], actual: $result['rows']); + self::assertCount(expectedCount: 1, haystack: $result['errors']); + self::assertSame(expected: 1, actual: $result['errors'][0]['line']); + self::assertSame(expected: 'header', actual: $result['errors'][0]['code']); + self::assertSame(expected: 'The first line must be: group, subject, teacher, lessons per week, lesson length, room type.', actual: $result['errors'][0]['message']); + }//end testAWrongHeaderIsRefused() + + /** + * Lessons per week and lesson length must be whole numbers of 1 or more; an unknown room type is refused; each names its line. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + public function testEachRefusalNamesItsLine(): void { + $csv = implode( + "\n", + [ + 'group,subject,teacher,lessons per week,lesson length,room type', + '3A,English,klaas,3,1,classroom', + '3A,Maths,noor,three,1,classroom', + '3B,Maths,noor,2,1.5,classroom', + '3B,Music,piet,0,1,classroom', + '3B,Swimming,piet,1,2,pool', + '3C,,piet,1,1,classroom', + ] + ); + $result = (new TimetableCsvParser())->parseActivities(csv: $csv, roomTypes: ['classroom']); + + self::assertSame(expected: ['3A:English'], actual: array_map(static fn (array $row): string => $row['group'].':'.$row['subject'], $result['rows'])); + self::assertSame( + expected: [ + [3, 'lessons per week', 'whole-number'], + [4, 'lesson length', 'whole-number'], + [5, 'lessons per week', 'whole-number'], + [6, 'room type', 'unknown-room-type'], + [7, 'subject', 'empty'], + ], + actual: array_map(static fn (array $error): array => [$error['line'], $error['field'], $error['code']], $result['errors']) + ); + self::assertSame(expected: 'Line 6: room type pool is not on the rooms sheet.', actual: $result['errors'][3]['message']); + }//end testEachRefusalNamesItsLine() + + /** + * A room capacity must be a whole number too. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + public function testARoomCapacityMustBeAWholeNumber(): void { + $result = (new TimetableCsvParser())->parseRooms(csv: "reference,capacity,type\nr-101,thirty,classroom\n"); + + self::assertSame(expected: [], actual: $result['rows']); + self::assertSame(expected: 'Line 2: capacity must be a whole number of 1 or more.', actual: $result['errors'][0]['message']); + }//end testARoomCapacityMustBeAWholeNumber() +}//end class diff --git a/tests/unit/Service/TimetableInputBuilderTest.php b/tests/unit/Service/TimetableInputBuilderTest.php new file mode 100644 index 00000000..4e9cbbc4 --- /dev/null +++ b/tests/unit/Service/TimetableInputBuilderTest.php @@ -0,0 +1,183 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Tests\Unit\Service; + +require_once __DIR__ . '/../Support/RegisterSchemaValidation.php'; + +use OCA\Planninq\Event\TimetableActivitiesQueryEvent; +use OCA\Planninq\Service\TimetableGridService; +use OCA\Planninq\Service\TimetableInputBuilder; +use OCA\Planninq\Tests\Unit\Support\RegisterSchemaValidation; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IAppConfig; +use PHPUnit\Framework\TestCase; + +/** + * The builder asks the hour plan's owner through the real event, falls back to + * the uploaded sheets, and says why an input is empty. + */ +class TimetableInputBuilderTest extends TestCase { + use RegisterSchemaValidation; + + /** + * The app config values, in memory. + * + * @var array + */ + private array $stored = []; + + /** + * A builder whose dispatcher runs $listener on the real event, as learniq's listener would. + * + * @param callable|null $listener Receives the TimetableActivitiesQueryEvent, or null for nobody listening. + * + * @return TimetableInputBuilder + */ + private function builder(?callable $listener): TimetableInputBuilder { + $dispatcher = $this->createMock(IEventDispatcher::class); + $dispatcher->method('dispatchTyped')->willReturnCallback( + static function (object $event) use ($listener): void { + if ($listener !== null && $event instanceof TimetableActivitiesQueryEvent) { + $listener($event); + } + } + ); + + $config = $this->createMock(IAppConfig::class); + $config->method('getValueString')->willReturnCallback( + fn (string $app, string $key, string $default = '', bool $lazy = false): string => ($this->stored[$key] ?? $default) + ); + $config->method('setValueString')->willReturnCallback( + function (string $app, string $key, string $value, bool $lazy = false): bool { + $this->stored[$key] = $value; + return true; + } + ); + + return new TimetableInputBuilder(dispatcher: $dispatcher, appConfig: $config, grid: new TimetableGridService(appConfig: $config)); + }//end builder() + + /** + * Learniq's hour plan fixture: 3A has three English lessons and two double maths lessons. + * + * @return array{0:array>,1:array>} + */ + private function hourPlan(): array { + return [ + [ + ['group' => '3A', 'subject' => 'English', 'teacher' => 'klaas', 'lessonsPerWeek' => 3, 'lessonLength' => 1, 'roomType' => 'classroom'], + ['group' => '3A', 'subject' => 'Maths', 'teacher' => 'noor', 'lessonsPerWeek' => 2, 'lessonLength' => 2, 'roomType' => 'classroom'], + ], + [['reference' => 'r-101', 'capacity' => 30, 'type' => 'classroom']], + ]; + }//end hourPlan() + + /** + * An answered event gives one lesson per weekly lesson, the grid's forty periods and the wishes with their ids. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + public function testAnAnsweredEventBecomesLessons(): void { + [$activities, $rooms] = $this->hourPlan(); + $asked = null; + $input = $this->builder( + static function (TimetableActivitiesQueryEvent $event) use ($activities, $rooms, &$asked): void { + $asked = $event->getAcademicYear(); + $event->answer(app: 'learniq', activities: $activities, rooms: $rooms); + } + )->build( + academicYear: '2026-2027', + wishes: [['@self' => ['id' => 'w-1'], 'appliesTo' => 'teacher', 'reference' => 'klaas', 'kind' => 'unavailable', 'periods' => ['wed-5'], 'strength' => 'hard', 'note' => 'not needed']] + ); + + self::assertSame(expected: '2026-2027', actual: $asked); + self::assertSame(expected: 'learniq', actual: $input->source); + self::assertNull(actual: $input->reason); + self::assertCount(expectedCount: 40, haystack: $input->periods); + self::assertSame(expected: ['mon-1', 'mon-2'], actual: array_slice($input->periods, 0, 2)); + self::assertSame(expected: 'fri-8', actual: $input->periods[39]); + self::assertSame(expected: $rooms, actual: $input->rooms); + self::assertSame(expected: ['3A:English:1', '3A:English:2', '3A:English:3', '3A:Maths:1', '3A:Maths:2'], actual: array_column($input->lessons, 'key')); + self::assertSame( + expected: ['key' => '3A:Maths:1', 'activity' => '3A:Maths', 'group' => '3A', 'subject' => 'Maths', 'teacher' => 'noor', 'roomType' => 'classroom', 'length' => 2], + actual: $input->lessons[3] + ); + self::assertSame( + expected: [['id' => 'w-1', 'appliesTo' => 'teacher', 'reference' => 'klaas', 'kind' => 'unavailable', 'periods' => ['wed-5'], 'strength' => 'hard']], + actual: $input->wishes + ); + + $scenario = ['title' => 'Autumn', 'source' => 'generated', 'weekOf' => '2026-10-05', 'windowFrom' => '2026-10-05', 'windowTo' => '2026-10-30', 'status' => 'queued', 'input' => $input->toArray()]; + self::assertSame(expected: [], actual: $this->registerSchemaErrors(slug: 'timetableScenario', payload: $scenario), message: 'the input fits the scenario schema'); + }//end testAnAnsweredEventBecomesLessons() + + /** + * Nobody answers and nothing was uploaded: an empty input and the reason. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + public function testAnUnansweredEventWithoutUploadGivesTheReason(): void { + $input = $this->builder(listener: null)->build(academicYear: '2026-2027', wishes: []); + + self::assertTrue(condition: $input->isEmpty()); + self::assertSame(expected: 'none', actual: $input->source); + self::assertSame(expected: 'No activities: learniq did not answer and no CSV was uploaded', actual: $input->reason); + self::assertCount(expectedCount: 40, haystack: $input->periods); + }//end testAnUnansweredEventWithoutUploadGivesTheReason() + + /** + * Nobody answers but an admin uploaded sheets: those are the input. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + public function testUploadedSheetsAreTheFallback(): void { + [$activities, $rooms] = $this->hourPlan(); + $builder = $this->builder(listener: null); + $builder->storeUpload(rooms: $rooms, activities: $activities); + + $input = $builder->build(academicYear: '2026-2027', wishes: []); + self::assertSame(expected: 'csv', actual: $input->source); + self::assertCount(expectedCount: 5, haystack: $input->lessons); + self::assertSame(expected: $rooms, actual: $input->rooms); + }//end testUploadedSheetsAreTheFallback() + + /** + * A narrower grid gives fewer period keys. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + public function testThePeriodsFollowTheStoredGrid(): void { + $this->stored['timetable_period_grid'] = '{"days":["mon","wed"],"periods":[{"start":"08:00","end":"08:45"},{"start":"08:45","end":"09:30"}]}'; + $input = $this->builder(listener: null)->build(academicYear: '2026-2027', wishes: []); + + self::assertSame(expected: ['mon-1', 'mon-2', 'wed-1', 'wed-2'], actual: $input->periods); + }//end testThePeriodsFollowTheStoredGrid() +}//end class From e40b82c9584125eefd36a032941993380d4e60b1 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 30 Sep 2026 00:02:46 +0200 Subject: [PATCH 2/3] style(timetable): two lines under 150 characters (phpcs) --- lib/Service/TimetableCsvParser.php | 3 ++- lib/Service/TimetableInputBuilder.php | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/Service/TimetableCsvParser.php b/lib/Service/TimetableCsvParser.php index f2ce499f..0c00df08 100644 --- a/lib/Service/TimetableCsvParser.php +++ b/lib/Service/TimetableCsvParser.php @@ -242,7 +242,8 @@ private function knownRoomType(string $type, array $roomTypes, int $line, array return true; } - $errors[] = $this->error(line: $line, field: 'room type', code: 'unknown-room-type', message: "Line {$line}: room type {$type} is not on the rooms sheet."); + $message = "Line {$line}: room type {$type} is not on the rooms sheet."; + $errors[] = $this->error(line: $line, field: 'room type', code: 'unknown-room-type', message: $message); return false; }//end knownRoomType() diff --git a/lib/Service/TimetableInputBuilder.php b/lib/Service/TimetableInputBuilder.php index 5526c9b3..02c6d4a2 100644 --- a/lib/Service/TimetableInputBuilder.php +++ b/lib/Service/TimetableInputBuilder.php @@ -114,7 +114,8 @@ public function build(string $academicYear, array $wishes): SolverInput { return new SolverInput(periods: $periods, rooms: [], lessons: [], wishes: $wishes, source: 'none', reason: self::NO_ACTIVITIES); } - return new SolverInput(periods: $periods, rooms: array_values($rooms), lessons: $this->lessons(activities: $activities), wishes: $wishes, source: $source); + $lessons = $this->lessons(activities: $activities); + return new SolverInput(periods: $periods, rooms: array_values($rooms), lessons: $lessons, wishes: $wishes, source: $source); }//end build() /** From 621476e263657bbad76cee23742d7085508f3994 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 30 Sep 2026 00:05:34 +0200 Subject: [PATCH 3/3] feat(timetable): the scorer measures clashes, wishes, gaps, lessons per day and room use --- lib/Timetabling/TimetableScorer.php | 384 ++++++++++++++++++ lib/Timetabling/WeekGrid.php | 135 ++++++ lib/Timetabling/WishChecker.php | 208 ++++++++++ .../changes/timetabling-generator/tasks.md | 2 +- .../unit/Timetabling/TimetableScorerTest.php | 323 +++++++++++++++ 5 files changed, 1051 insertions(+), 1 deletion(-) create mode 100644 lib/Timetabling/TimetableScorer.php create mode 100644 lib/Timetabling/WeekGrid.php create mode 100644 lib/Timetabling/WishChecker.php create mode 100644 tests/unit/Timetabling/TimetableScorerTest.php diff --git a/lib/Timetabling/TimetableScorer.php b/lib/Timetabling/TimetableScorer.php new file mode 100644 index 00000000..29e47500 --- /dev/null +++ b/lib/Timetabling/TimetableScorer.php @@ -0,0 +1,384 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Timetabling; + +/** + * Scores placements (lesson key, first period key, room reference) against a SolverInput. + * + * Clash kinds: `teacher`, `group` and `room` (two lessons in one period), + * `roomType` (a room of another type than the lesson needs), `unknownRoom` + * and `outOfGrid` (a lesson that starts outside the grid or runs off the day). + * A broken soft wish costs its weight (1 when unset) for every lesson that breaks it. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ +final class TimetableScorer { + + /** + * Cost of one clash; above everything else, so no clash is traded for a wish. + */ + public const CLASH_COST = 1000000; + + /** + * Cost of one broken hard wish. + */ + public const HARD_COST = 100000; + + /** + * Cost of one lesson that is not placed. + */ + public const UNPLACED_COST = 10000; + + /** + * Cost of one point of soft penalty. + */ + public const SOFT_COST = 10; + + /** + * Cost of one free period between a teacher's lessons. + */ + public const GAP_COST = 1; + + /** + * Constructor. + * + * @param WishChecker $wishes Evaluates the wishes. + * + * @return void + */ + public function __construct( + private readonly WishChecker $wishes=new WishChecker(), + ) { + }//end __construct() + + /** + * The score of a week. + * + * @param SolverInput $input What was to be placed. + * @param array $placements Where the lessons are. + * + * @return array{metrics:array,brokenWishes:array>,clashes:array>} + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ + public function score(SolverInput $input, array $placements): array { + $grid = new WeekGrid(periods: $input->periods); + $clashes = []; + $slots = $this->slots(input: $input, placements: $placements, grid: $grid, clashes: $clashes); + $clashes = array_merge($clashes, $this->doubleBookings(slots: $slots)); + $broken = $this->brokenWishes(wishes: $input->wishes, slots: $slots, grid: $grid); + + $metrics = array_merge( + [ + 'lessons' => count($input->lessons), + 'placed' => count($slots), + 'unplaced' => (count($input->lessons) - count($slots)), + 'clashes' => count($clashes), + ], + $this->wishMetrics(broken: $broken), + $this->teacherGaps(slots: $slots, grid: $grid), + ['lessonsPerDayWorst' => $this->lessonsPerDayWorst(slots: $slots)], + $this->roomUse(input: $input, slots: $slots) + ); + + return ['metrics' => $metrics, 'brokenWishes' => $broken, 'clashes' => $clashes]; + }//end score() + + /** + * One number to minimise: clashes, then hard wishes, then unplaced lessons, then soft wishes, then gaps. + * + * @param array $metrics The metrics of score(). + * + * @return int + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ + public static function cost(array $metrics): int { + return ((int)$metrics['clashes'] * self::CLASH_COST) + + ((int)$metrics['hardWishesBroken'] * self::HARD_COST) + + ((int)$metrics['unplaced'] * self::UNPLACED_COST) + + ((int)$metrics['softPenalty'] * self::SOFT_COST) + + ((int)$metrics['teacherGaps'] * self::GAP_COST); + }//end cost() + + /** + * The placed lessons with the periods they take; a placement that cannot be laid out is a clash. + * + * @param SolverInput $input The input. + * @param array> $placements The placements. + * @param WeekGrid $grid The grid. + * @param array> $clashes Collects the clashes. + * + * @return array> + */ + private function slots(SolverInput $input, array $placements, WeekGrid $grid, array &$clashes): array { + $lessons = array_column($input->lessons, null, 'key'); + $rooms = array_column($input->rooms, null, 'reference'); + $order = array_flip($grid->keys()); + $slots = []; + foreach ($placements as $placement) { + $lesson = ($lessons[(string)$placement['lesson']] ?? null); + if ($lesson === null) { + continue; + } + + $room = (string)$placement['room']; + $periods = $grid->span(start: (string)$placement['period'], length: (int)($lesson['length'] ?? 1)); + $problem = $this->placementProblem(lesson: $lesson, room: ($rooms[$room] ?? null), periods: $periods); + if ($problem !== null) { + $clashes[] = ['kind' => $problem, 'reference' => $room, 'period' => (string)$placement['period'], 'lessons' => [(string)$lesson['key']]]; + } + + if ($periods === null) { + continue; + } + + $slots[] = [ + 'lesson' => $lesson, + 'room' => $room, + 'periods' => $periods, + 'day' => $grid->day(key: $periods[0]), + 'order' => (int)$order[$periods[0]], + ]; + }//end foreach + + return $slots; + }//end slots() + + /** + * What is wrong with one placement by itself, or null. + * + * @param array $lesson The lesson. + * @param array|null $room The room, or null when unknown. + * @param array|null $periods The periods, or null when off the grid. + * + * @return string|null + */ + private function placementProblem(array $lesson, ?array $room, ?array $periods): ?string { + if ($periods === null) { + return 'outOfGrid'; + } + + if ($room === null) { + return 'unknownRoom'; + } + + $needed = (string)($lesson['roomType'] ?? ''); + if ($needed !== '' && (string)($room['type'] ?? '') !== $needed) { + return 'roomType'; + } + + return null; + }//end placementProblem() + + /** + * Two lessons of one teacher, group or room in the same period: one clash per resource and period. + * + * @param array> $slots The placed lessons. + * + * @return array> + */ + private function doubleBookings(array $slots): array { + $taken = []; + foreach ($slots as $slot) { + $owners = ['teacher' => (string)$slot['lesson']['teacher'], 'group' => (string)$slot['lesson']['group'], 'room' => (string)$slot['room']]; + foreach ($slot['periods'] as $period) { + foreach ($owners as $kind => $reference) { + $taken[$kind."\n".$reference."\n".$period][] = (string)$slot['lesson']['key']; + } + } + } + + $clashes = []; + foreach ($taken as $at => $lessons) { + if (count($lessons) > 1 && explode("\n", $at)[1] !== '') { + [$kind, $reference, $period] = explode("\n", $at); + $clashes[] = ['kind' => $kind, 'reference' => $reference, 'period' => $period, 'lessons' => $lessons]; + } + } + + return $clashes; + }//end doubleBookings() + + /** + * Every broken wish with the lessons that break it. + * + * @param array> $wishes The wishes. + * @param array> $slots The placed lessons. + * @param WeekGrid $grid The week grid. + * + * @return array> + */ + private function brokenWishes(array $wishes, array $slots, WeekGrid $grid): array { + $broken = []; + foreach ($wishes as $wish) { + $lessons = $this->wishes->breakingLessons(wish: $wish, slots: $slots, grid: $grid); + if ($lessons === []) { + continue; + } + + $hard = ($wish['strength'] ?? 'soft') === 'hard'; + $broken[] = [ + 'wish' => (string)($wish['id'] ?? ''), + 'strength' => (string)($wish['strength'] ?? 'soft'), + 'weight' => $this->weight(wish: $wish, hard: $hard), + 'lessons' => $lessons, + ]; + } + + return $broken; + }//end brokenWishes() + + /** + * The weight of a broken wish: null for a hard wish, else 1 to 3 (1 when unset). + * + * @param array $wish The wish. + * @param bool $hard Whether it is hard. + * + * @return int|null + */ + private function weight(array $wish, bool $hard): ?int { + if ($hard === true) { + return null; + } + + return max(1, (int)($wish['weight'] ?? 1)); + }//end weight() + + /** + * Hard and soft wishes broken, and the weighted soft penalty. + * + * @param array> $broken The broken wishes. + * + * @return array{hardWishesBroken:int,softWishesBroken:int,softPenalty:int} + */ + private function wishMetrics(array $broken): array { + $metrics = ['hardWishesBroken' => 0, 'softWishesBroken' => 0, 'softPenalty' => 0]; + foreach ($broken as $wish) { + if ($wish['strength'] === 'hard') { + $metrics['hardWishesBroken']++; + continue; + } + + $metrics['softWishesBroken']++; + $metrics['softPenalty'] += ((int)$wish['weight'] * count($wish['lessons'])); + } + + return $metrics; + }//end wishMetrics() + + /** + * Free periods between a teacher's lessons on a day: in total and for the teacher with the most. + * + * @param array> $slots The placed lessons. + * @param WeekGrid $grid The week grid. + * + * @return array{teacherGaps:int,teacherGapsWorst:int} + */ + private function teacherGaps(array $slots, WeekGrid $grid): array { + $numbers = []; + foreach ($slots as $slot) { + foreach ($slot['periods'] as $period) { + $numbers[(string)$slot['lesson']['teacher']][$grid->day(key: $period)][] = $grid->number(key: $period); + } + } + + $total = 0; + $worst = 0; + foreach ($numbers as $days) { + $gaps = array_sum(array_map([$grid, 'gaps'], $days)); + $total += $gaps; + $worst = max($worst, $gaps); + } + + return ['teacherGaps' => $total, 'teacherGapsWorst' => $worst]; + }//end teacherGaps() + + /** + * The most lessons one group has on one day. + * + * @param array> $slots The placed lessons. + * + * @return int + */ + private function lessonsPerDayWorst(array $slots): int { + $counts = []; + foreach ($slots as $slot) { + $key = (string)$slot['lesson']['group']."\n".(string)$slot['day']; + $counts[$key] = (($counts[$key] ?? 0) + 1); + } + + if ($counts === []) { + return 0; + } + + return (int)max($counts); + }//end lessonsPerDayWorst() + + /** + * The share of room periods in use, overall and per room type, rounded to two decimals. + * + * @param SolverInput $input The input. + * @param array> $slots The placed lessons. + * + * @return array{roomUse:float,roomUseByType:array} + */ + private function roomUse(SolverInput $input, array $slots): array { + $types = array_column($input->rooms, 'type', 'reference'); + $used = []; + foreach ($slots as $slot) { + $type = ($types[(string)$slot['room']] ?? null); + if ($type !== null) { + $used[(string)$type] = (($used[(string)$type] ?? 0) + count($slot['periods'])); + } + } + + $periods = count($input->periods); + $byType = []; + foreach (array_count_values(array_map('strval', array_values($types))) as $type => $rooms) { + $byType[(string)$type] = $this->share(used: ($used[$type] ?? 0), available: ($rooms * $periods)); + } + + return ['roomUse' => $this->share(used: array_sum($used), available: (count($types) * $periods)), 'roomUseByType' => $byType]; + }//end roomUse() + + /** + * A share rounded to two decimals, 0.0 when nothing is available. + * + * @param int $used Used. + * @param int $available Available. + * + * @return float + */ + private function share(int $used, int $available): float { + if ($available === 0) { + return 0.0; + } + + return round($used / $available, 2); + }//end share() +}//end class diff --git a/lib/Timetabling/WeekGrid.php b/lib/Timetabling/WeekGrid.php new file mode 100644 index 00000000..196044f6 --- /dev/null +++ b/lib/Timetabling/WeekGrid.php @@ -0,0 +1,135 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Timetabling; + +/** + * Days and period numbers of a list of period keys such as `mon-3`. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ +final class WeekGrid { + + /** + * The period keys, as a set. + * + * @var array + */ + private array $known = []; + + /** + * Constructor. + * + * @param array $periods The period keys of the input. + * + * @return void + */ + public function __construct(private readonly array $periods) { + foreach ($periods as $key) { + $this->known[$key] = true; + } + }//end __construct() + + /** + * The period keys, in grid order. + * + * @return array + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ + public function keys(): array { + return $this->periods; + }//end keys() + + /** + * The periods a lesson of $length periods starting at $start takes, or null when it runs off the day or starts outside the grid. + * + * @param string $start The first period key. + * @param int $length The length in periods. + * + * @return array|null + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ + public function span(string $start, int $length): ?array { + $day = $this->day(key: $start); + $number = $this->number(key: $start); + $keys = []; + for ($offset = 0; $offset < max(1, $length); $offset++) { + $key = $day.'-'.($number + $offset); + if (isset($this->known[$key]) === false) { + return null; + } + + $keys[] = $key; + } + + return $keys; + }//end span() + + /** + * The day of a period key, as in `mon`. + * + * @param string $key The period key. + * + * @return string + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ + public function day(string $key): string { + return explode('-', $key, 2)[0]; + }//end day() + + /** + * The period number of a period key, from 1. + * + * @param string $key The period key. + * + * @return int + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ + public function number(string $key): int { + return (int)(explode('-', $key, 2)[1] ?? 0); + }//end number() + + /** + * The free periods between the first and the last of a day's taken period numbers. + * + * @param array $numbers Taken period numbers of one day. + * + * @return int + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ + public function gaps(array $numbers): int { + if ($numbers === []) { + return 0; + } + + $unique = array_unique($numbers); + return (max($unique) - min($unique) + 1 - count($unique)); + }//end gaps() +}//end class diff --git a/lib/Timetabling/WishChecker.php b/lib/Timetabling/WishChecker.php new file mode 100644 index 00000000..cf95890e --- /dev/null +++ b/lib/Timetabling/WishChecker.php @@ -0,0 +1,208 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Timetabling; + +/** + * The five wish kinds, evaluated on placed lessons. + * + * A placed lesson (a slot) is: `lesson` (the input lesson), `room`, `periods` + * (the period keys it takes), `day` and `order` (the grid position of its first period). + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ +final class WishChecker { + + /** + * The lesson field a wish on each subject compares with its reference. + */ + private const LESSON_FIELDS = [ + 'teacher' => 'teacher', + 'group' => 'group', + 'activity' => 'activity', + ]; + + /** + * The keys of the lessons that break the wish, in period order; empty when it is kept. + * + * @param array $wish The wish. + * @param array> $slots All placed lessons. + * @param WeekGrid $grid The week grid. + * + * @return array + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ + public function breakingLessons(array $wish, array $slots, WeekGrid $grid): array { + $mine = array_values( + array_filter($slots, fn (array $slot): bool => $this->applies(wish: $wish, lesson: $slot['lesson'], room: (string)$slot['room'])) + ); + usort($mine, static fn (array $one, array $two): int => $one['order'] <=> $two['order']); + + $breaking = match ((string)($wish['kind'] ?? '')) { + 'unavailable', 'avoid' => array_filter($mine, fn (array $slot): bool => $this->touches(wish: $wish, periods: $slot['periods'])), + 'maxPerDay' => $this->overTheLimit(wish: $wish, slots: $mine), + 'noGaps' => $this->daysWithGaps(slots: $mine, grid: $grid), + 'sameRoom' => $this->outsideTheMainRoom(slots: $mine), + default => [], + }; + + return array_values(array_map(static fn (array $slot): string => (string)$slot['lesson']['key'], $breaking)); + }//end breakingLessons() + + /** + * Whether placing the lesson on these periods in this room breaks the wish by itself (the period kinds only). + * + * @param array $wish The wish. + * @param array $lesson The lesson. + * @param array $periods The periods it would take. + * @param string $room The room. + * + * @return bool + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ + public function forbids(array $wish, array $lesson, array $periods, string $room): bool { + if (in_array(($wish['kind'] ?? ''), ['unavailable', 'avoid'], true) === false) { + return false; + } + + return $this->applies(wish: $wish, lesson: $lesson, room: $room) === true && $this->touches(wish: $wish, periods: $periods) === true; + }//end forbids() + + /** + * Whether a wish is about this lesson. + * + * @param array $wish The wish. + * @param array $lesson The lesson. + * @param string $room The room it is in. + * + * @return bool + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ + public function applies(array $wish, array $lesson, string $room): bool { + $reference = (string)($wish['reference'] ?? ''); + $appliesTo = (string)($wish['appliesTo'] ?? ''); + if ($appliesTo === 'room') { + return $room === $reference; + } + + $field = (self::LESSON_FIELDS[$appliesTo] ?? null); + return $field !== null && (string)($lesson[$field] ?? '') === $reference; + }//end applies() + + /** + * Whether the periods include one of the wish's periods. + * + * @param array $wish The wish. + * @param array $periods The periods. + * + * @return bool + */ + private function touches(array $wish, array $periods): bool { + return array_intersect($periods, (array)($wish['periods'] ?? [])) !== []; + }//end touches() + + /** + * The lessons past the wish's limit on each day. + * + * @param array $wish The wish. + * @param array> $slots Its lessons, in period order. + * + * @return array> + */ + private function overTheLimit(array $wish, array $slots): array { + $limit = (int)($wish['limit'] ?? 0); + if ($limit < 1) { + return []; + } + + $over = []; + foreach ($this->byDay(slots: $slots) as $day) { + $over = array_merge($over, array_slice($day, $limit)); + } + + return $over; + }//end overTheLimit() + + /** + * The lessons of every day that has a free period between two of them. + * + * @param array> $slots Its lessons, in period order. + * @param WeekGrid $grid The week grid. + * + * @return array> + */ + private function daysWithGaps(array $slots, WeekGrid $grid): array { + $broken = []; + foreach ($this->byDay(slots: $slots) as $day) { + $numbers = []; + foreach ($day as $slot) { + $numbers = array_merge($numbers, array_map([$grid, 'number'], $slot['periods'])); + } + + if ($grid->gaps(numbers: $numbers) > 0) { + $broken = array_merge($broken, $day); + } + } + + return $broken; + }//end daysWithGaps() + + /** + * The lessons that are not in the room most of them use. + * + * @param array> $slots Its lessons, in period order. + * + * @return array> + */ + private function outsideTheMainRoom(array $slots): array { + $counts = array_count_values(array_map(static fn (array $slot): string => (string)$slot['room'], $slots)); + if (count($counts) < 2) { + return []; + } + + arsort($counts); + $main = (string)array_key_first($counts); + return array_filter($slots, static fn (array $slot): bool => (string)$slot['room'] !== $main); + }//end outsideTheMainRoom() + + /** + * Lessons grouped by day, order kept. + * + * @param array> $slots Lessons in period order. + * + * @return array>> + */ + private function byDay(array $slots): array { + $days = []; + foreach ($slots as $slot) { + $days[(string)$slot['day']][] = $slot; + } + + return $days; + }//end byDay() +}//end class diff --git a/openspec/changes/timetabling-generator/tasks.md b/openspec/changes/timetabling-generator/tasks.md index c4383c4e..924ea0d1 100644 --- a/openspec/changes/timetabling-generator/tasks.md +++ b/openspec/changes/timetabling-generator/tasks.md @@ -21,7 +21,7 @@ DECISIONS.md row 17). Sections 5 onward assume the solver answer recorded in des ## 4. Scorer -- [ ] 4.1 `TimetableScorer`: clashes, hard wish breaches, soft wish breaches with weights, teacher gaps, lessons per day, room use, as `metrics` (design decision 5). Verify: PHPUnit with a hand-made placement for each wish kind, each broken once and kept once. +- [x] 4.1 `TimetableScorer`: clashes, hard wish breaches, soft wish breaches with weights, teacher gaps, lessons per day, room use, as `metrics` (design decision 5). Verify: PHPUnit with a hand-made placement for each wish kind, each broken once and kept once. ## 5. Solver and the background run diff --git a/tests/unit/Timetabling/TimetableScorerTest.php b/tests/unit/Timetabling/TimetableScorerTest.php new file mode 100644 index 00000000..d63d5a9f --- /dev/null +++ b/tests/unit/Timetabling/TimetableScorerTest.php @@ -0,0 +1,323 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Tests\Unit\Timetabling; + +require_once __DIR__ . '/../Support/RegisterSchemaValidation.php'; + +use OCA\Planninq\Tests\Unit\Support\RegisterSchemaValidation; +use OCA\Planninq\Timetabling\SolverInput; +use OCA\Planninq\Timetabling\TimetableScorer; +use PHPUnit\Framework\TestCase; + +/** + * Hand-made placements on a two-day, four-period week. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ +class TimetableScorerTest extends TestCase { + use RegisterSchemaValidation; + + /** + * Lessons of the fixture: klaas teaches 3A English three times, noor 3A Maths once (two periods), piet 3B Art once in a studio. + * + * @return array> + */ + private function lessons(): array { + $lesson = static fn (string $key, string $group, string $subject, string $teacher, string $roomType='classroom', int $length=1): array => [ + 'key' => $key, + 'activity' => $group.':'.$subject, + 'group' => $group, + 'subject' => $subject, + 'teacher' => $teacher, + 'roomType' => $roomType, + 'length' => $length, + ]; + + return [ + $lesson('3A:English:1', '3A', 'English', 'klaas'), + $lesson('3A:English:2', '3A', 'English', 'klaas'), + $lesson('3A:English:3', '3A', 'English', 'klaas'), + $lesson('3A:Maths:1', '3A', 'Maths', 'noor', 'classroom', 2), + $lesson('3B:Art:1', '3B', 'Art', 'piet', 'studio'), + ]; + }//end lessons() + + /** + * The input with the given wishes. + * + * @param array> $wishes The wishes. + * + * @return SolverInput + */ + private function input(array $wishes=[]): SolverInput { + return new SolverInput( + periods: ['mon-1', 'mon-2', 'mon-3', 'mon-4', 'tue-1', 'tue-2', 'tue-3', 'tue-4'], + rooms: [ + ['reference' => 'r-1', 'capacity' => 30, 'type' => 'classroom'], + ['reference' => 'r-2', 'capacity' => 30, 'type' => 'classroom'], + ['reference' => 's-1', 'capacity' => 20, 'type' => 'studio'], + ], + lessons: $this->lessons(), + wishes: $wishes, + source: 'csv', + ); + }//end input() + + /** + * A placement row. + * + * @param string $lesson The lesson key. + * @param string $period The first period. + * @param string $room The room. + * + * @return array{lesson:string,period:string,room:string} + */ + private function at(string $lesson, string $period, string $room='r-1'): array { + return ['lesson' => $lesson, 'period' => $period, 'room' => $room]; + }//end at() + + /** + * A clean week: English mon-1, mon-2, tue-1; Maths mon-3/4; Art tue-2. + * + * @return array + */ + private function cleanWeek(): array { + return [ + $this->at('3A:English:1', 'mon-1'), + $this->at('3A:English:2', 'mon-2'), + $this->at('3A:English:3', 'tue-1'), + $this->at('3A:Maths:1', 'mon-3', 'r-2'), + $this->at('3B:Art:1', 'tue-2', 's-1'), + ]; + }//end cleanWeek() + + /** + * The broken wish ids of a score. + * + * @param array $score The score. + * + * @return array + */ + private function brokenIds(array $score): array { + return array_column($score['brokenWishes'], 'wish'); + }//end brokenIds() + + /** + * A clean week has no clashes, nothing unplaced and the measures compare shows. + * + * @return void + */ + public function testACleanWeekHasTheMeasures(): void { + $score = (new TimetableScorer())->score(input: $this->input(), placements: $this->cleanWeek()); + $metrics = $score['metrics']; + + self::assertSame(expected: [], actual: $score['clashes']); + self::assertSame(expected: 5, actual: $metrics['lessons']); + self::assertSame(expected: 5, actual: $metrics['placed']); + self::assertSame(expected: 0, actual: $metrics['unplaced']); + self::assertSame(expected: 0, actual: $metrics['clashes']); + self::assertSame(expected: 0, actual: $metrics['hardWishesBroken']); + self::assertSame(expected: 0, actual: $metrics['softWishesBroken']); + self::assertSame(expected: 0, actual: $metrics['softPenalty']); + // Noor: mon-3 and mon-4: no gap. Klaas: mon-1, mon-2 and tue-1: no gap. + self::assertSame(expected: 0, actual: $metrics['teacherGaps']); + self::assertSame(expected: 0, actual: $metrics['teacherGapsWorst']); + // 3A on Monday: English twice and Maths once. + self::assertSame(expected: 3, actual: $metrics['lessonsPerDayWorst']); + // Six room-periods used of 3 rooms x 8 periods. + self::assertSame(expected: 0.25, actual: $metrics['roomUse']); + self::assertSame(expected: ['classroom' => 0.31, 'studio' => 0.13], actual: $metrics['roomUseByType']); + self::assertSame(expected: 0, actual: TimetableScorer::cost(metrics: $metrics)); + }//end testACleanWeekHasTheMeasures() + + /** + * Teacher, group and room clashes, a wrong room type, a lesson running off the day and an unknown room are counted. + * + * @return void + */ + public function testClashesAreCounted(): void { + $week = $this->cleanWeek(); + $week[1] = $this->at('3A:English:2', 'mon-1', 'r-2'); + // Teacher klaas and group 3A twice in mon-1. + $week[4] = $this->at('3B:Art:1', 'mon-4', 'r-2'); + // Room r-2 twice in mon-4 (Maths runs mon-3 and mon-4), and Art in a classroom. + $score = (new TimetableScorer())->score(input: $this->input(), placements: $week); + + $kinds = array_column($score['clashes'], 'kind'); + sort($kinds); + self::assertSame(expected: ['group', 'room', 'roomType', 'teacher'], actual: $kinds); + self::assertSame(expected: 4, actual: $score['metrics']['clashes']); + self::assertGreaterThanOrEqual(expected: 4 * TimetableScorer::CLASH_COST, actual: TimetableScorer::cost(metrics: $score['metrics'])); + + $off = $this->cleanWeek(); + $off[3] = $this->at('3A:Maths:1', 'mon-4', 'r-2'); + $off[4] = $this->at('3B:Art:1', 'tue-2', 'nowhere'); + $kinds = array_column((new TimetableScorer())->score(input: $this->input(), placements: $off)['clashes'], 'kind'); + sort($kinds); + self::assertSame(expected: ['outOfGrid', 'unknownRoom'], actual: $kinds); + }//end testClashesAreCounted() + + /** + * A lesson with no placement is unplaced. + * + * @return void + */ + public function testALessonWithoutAPlacementIsUnplaced(): void { + $week = array_slice($this->cleanWeek(), 0, 4); + $metrics = (new TimetableScorer())->score(input: $this->input(), placements: $week)['metrics']; + + self::assertSame(expected: 4, actual: $metrics['placed']); + self::assertSame(expected: 1, actual: $metrics['unplaced']); + self::assertSame(expected: TimetableScorer::UNPLACED_COST, actual: TimetableScorer::cost(metrics: $metrics)); + }//end testALessonWithoutAPlacementIsUnplaced() + + /** + * Not on these periods (hard): broken when a lesson touches one, kept otherwise. + * + * @return void + */ + public function testUnavailableHard(): void { + $wish = ['id' => 'w-un', 'appliesTo' => 'teacher', 'reference' => 'noor', 'kind' => 'unavailable', 'periods' => ['mon-4'], 'strength' => 'hard']; + $score = (new TimetableScorer())->score(input: $this->input(wishes: [$wish]), placements: $this->cleanWeek()); + + self::assertSame(expected: ['w-un'], actual: $this->brokenIds(score: $score)); + self::assertSame(expected: ['3A:Maths:1'], actual: $score['brokenWishes'][0]['lessons']); + self::assertSame(expected: 'hard', actual: $score['brokenWishes'][0]['strength']); + self::assertSame(expected: 1, actual: $score['metrics']['hardWishesBroken']); + self::assertSame(expected: TimetableScorer::HARD_COST, actual: TimetableScorer::cost(metrics: $score['metrics'])); + + $wish['periods'] = ['mon-5', 'tue-3']; + self::assertSame(expected: [], actual: (new TimetableScorer())->score(input: $this->input(wishes: [$wish]), placements: $this->cleanWeek())['brokenWishes']); + }//end testUnavailableHard() + + /** + * Preferably not on these periods (soft, weight 2) on a group: broken, then kept. + * + * @return void + */ + public function testAvoidSoftWithWeight(): void { + $wish = ['id' => 'w-av', 'appliesTo' => 'group', 'reference' => '3A', 'kind' => 'avoid', 'periods' => ['mon-1', 'mon-2'], 'strength' => 'soft', 'weight' => 2]; + $score = (new TimetableScorer())->score(input: $this->input(wishes: [$wish]), placements: $this->cleanWeek()); + + self::assertSame(expected: ['3A:English:1', '3A:English:2'], actual: $score['brokenWishes'][0]['lessons']); + self::assertSame(expected: 2, actual: $score['brokenWishes'][0]['weight']); + self::assertSame(expected: 1, actual: $score['metrics']['softWishesBroken']); + // Weight 2 for each of the two lessons. + self::assertSame(expected: 4, actual: $score['metrics']['softPenalty']); + + $wish['periods'] = ['tue-4']; + self::assertSame(expected: 0, actual: (new TimetableScorer())->score(input: $this->input(wishes: [$wish]), placements: $this->cleanWeek())['metrics']['softPenalty']); + }//end testAvoidSoftWithWeight() + + /** + * At most N lessons a day on a teacher: the lessons over the limit are named. + * + * @return void + */ + public function testMaxPerDay(): void { + $wish = ['id' => 'w-max', 'appliesTo' => 'teacher', 'reference' => 'klaas', 'kind' => 'maxPerDay', 'limit' => 1, 'strength' => 'soft', 'weight' => 1]; + $score = (new TimetableScorer())->score(input: $this->input(wishes: [$wish]), placements: $this->cleanWeek()); + + self::assertSame(expected: ['3A:English:2'], actual: $score['brokenWishes'][0]['lessons']); + + $wish['limit'] = 2; + self::assertSame(expected: [], actual: (new TimetableScorer())->score(input: $this->input(wishes: [$wish]), placements: $this->cleanWeek())['brokenWishes']); + }//end testMaxPerDay() + + /** + * No free periods between lessons on a teacher: a gap breaks it; the gap also counts in the teacher gaps. + * + * @return void + */ + public function testNoGaps(): void { + $wish = ['id' => 'w-gap', 'appliesTo' => 'teacher', 'reference' => 'klaas', 'kind' => 'noGaps', 'strength' => 'soft', 'weight' => 3]; + $week = $this->cleanWeek(); + $week[1] = $this->at('3A:English:2', 'tue-4'); + // Klaas on Tuesday: periods 1 and 4, two free periods between them. + $score = (new TimetableScorer())->score(input: $this->input(wishes: [$wish]), placements: $week); + + self::assertSame(expected: ['3A:English:3', '3A:English:2'], actual: $score['brokenWishes'][0]['lessons']); + self::assertSame(expected: 2, actual: $score['metrics']['teacherGaps']); + self::assertSame(expected: 2, actual: $score['metrics']['teacherGapsWorst']); + + self::assertSame(expected: [], actual: (new TimetableScorer())->score(input: $this->input(wishes: [$wish]), placements: $this->cleanWeek())['brokenWishes']); + }//end testNoGaps() + + /** + * Always the same room on an activity: two rooms break it; the lessons outside the most used room are named. + * + * @return void + */ + public function testSameRoom(): void { + $wish = ['id' => 'w-room', 'appliesTo' => 'activity', 'reference' => '3A:English', 'kind' => 'sameRoom', 'strength' => 'hard']; + $week = $this->cleanWeek(); + $week[2] = $this->at('3A:English:3', 'tue-1', 'r-2'); + $score = (new TimetableScorer())->score(input: $this->input(wishes: [$wish]), placements: $week); + + self::assertSame(expected: ['3A:English:3'], actual: $score['brokenWishes'][0]['lessons']); + + self::assertSame(expected: [], actual: (new TimetableScorer())->score(input: $this->input(wishes: [$wish]), placements: $this->cleanWeek())['brokenWishes']); + }//end testSameRoom() + + /** + * A wish on a room applies to the lessons placed in it. + * + * @return void + */ + public function testAWishOnARoom(): void { + $wish = ['id' => 'w-r', 'appliesTo' => 'room', 'reference' => 's-1', 'kind' => 'unavailable', 'periods' => ['tue-2'], 'strength' => 'hard']; + $score = (new TimetableScorer())->score(input: $this->input(wishes: [$wish]), placements: $this->cleanWeek()); + + self::assertSame(expected: ['3B:Art:1'], actual: $score['brokenWishes'][0]['lessons']); + }//end testAWishOnARoom() + + /** + * A finished scenario carrying this score passes the real timetableScenario schema. + * + * @return void + */ + public function testTheScoreFitsTheScenarioSchema(): void { + $wishes = [ + ['id' => 'w-un', 'appliesTo' => 'teacher', 'reference' => 'noor', 'kind' => 'unavailable', 'periods' => ['mon-4'], 'strength' => 'hard'], + ['id' => 'w-av', 'appliesTo' => 'group', 'reference' => '3A', 'kind' => 'avoid', 'periods' => ['mon-1'], 'strength' => 'soft', 'weight' => 2], + ]; + $input = $this->input(wishes: $wishes); + $score = (new TimetableScorer())->score(input: $input, placements: $this->cleanWeek()); + + $scenario = [ + 'title' => 'Imported week', + 'source' => 'imported', + 'weekOf' => '2026-10-05', + 'windowFrom' => '2026-10-05', + 'windowTo' => '2026-10-30', + 'status' => 'done', + 'input' => $input->toArray(), + 'placements' => $this->cleanWeek(), + 'brokenWishes' => $score['brokenWishes'], + 'metrics' => $score['metrics'], + ]; + self::assertCount(expectedCount: 2, haystack: $score['brokenWishes']); + self::assertSame(expected: [], actual: $this->registerSchemaErrors(slug: 'timetableScenario', payload: $scenario)); + }//end testTheScoreFitsTheScenarioSchema() +}//end class