SprintImporterCommitTest.php 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. <?php
  2. /*
  3. * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
  4. * SPDX-License-Identifier: Apache-2.0
  5. *
  6. * Licensed under the Apache License, Version 2.0 (the "License");
  7. * you may not use this file except in compliance with the License.
  8. * See the LICENSE file in the project root for the full license text.
  9. */
  10. declare(strict_types=1);
  11. namespace App\Tests\Services\Import;
  12. use App\Domain\Import\ParsedAssignment;
  13. use App\Domain\Import\ParsedSheet;
  14. use App\Domain\Import\ParsedTask;
  15. use App\Domain\Import\ParsedWeek;
  16. use App\Domain\Import\ParsedWorker;
  17. use App\Domain\TaskAssignment;
  18. use App\Http\Request;
  19. use App\Repositories\SprintRepository;
  20. use App\Repositories\SprintWeekRepository;
  21. use App\Repositories\SprintWorkerDayRepository;
  22. use App\Repositories\SprintWorkerRepository;
  23. use App\Repositories\TaskAssignmentRepository;
  24. use App\Repositories\TaskRepository;
  25. use App\Repositories\WorkerRepository;
  26. use App\Services\AuditLogger;
  27. use App\Services\Import\SprintImporter;
  28. use App\Tests\TestCase;
  29. use PDO;
  30. use RuntimeException;
  31. /**
  32. * Phase 20 — commit-side test. Builds a ParsedSheet by hand (no XLSX, no
  33. * PhpSpreadsheet) and asserts that committing it produces the expected
  34. * sprint, weeks, workers, sprint_workers, sprint_worker_days, tasks,
  35. * task_assignments, and audit rows.
  36. */
  37. final class SprintImporterCommitTest extends TestCase
  38. {
  39. /**
  40. * @return array{0:PDO,1:SprintImporter,2:Request}
  41. */
  42. private function build(): array
  43. {
  44. $pdo = $this->makeDb();
  45. $importer = new SprintImporter(
  46. pdo: $pdo,
  47. sprints: new SprintRepository($pdo),
  48. weeks: new SprintWeekRepository($pdo),
  49. sprintWorkers:new SprintWorkerRepository($pdo),
  50. days: new SprintWorkerDayRepository($pdo),
  51. tasks: new TaskRepository($pdo),
  52. assignments: new TaskAssignmentRepository($pdo),
  53. workers: new WorkerRepository($pdo),
  54. audit: new AuditLogger($pdo),
  55. );
  56. $req = new Request('POST', '/sprints/import/x', [], [], '', [], []);
  57. return [$pdo, $importer, $req];
  58. }
  59. private function sampleSheet(): ParsedSheet
  60. {
  61. return new ParsedSheet(
  62. sheetName: 'Sprint 1',
  63. weeks: [
  64. new ParsedWeek(1, 13, '23.03', 2, '2026-03-23'),
  65. new ParsedWeek(2, 14, '30.03', 4, '2026-03-30'),
  66. ],
  67. workers: [
  68. new ParsedWorker('Alice', [1 => 2.0, 2 => 4.0], 0.5),
  69. new ParsedWorker('Bob', [1 => 1.0, 2 => 0.0], 0.7),
  70. ],
  71. tasks: [
  72. new ParsedTask('Build thing', 'Alice', 1, [
  73. new ParsedAssignment('Alice', 2.0, TaskAssignment::STATUS_ABGESCHLOSSEN, 'FF00B050'),
  74. new ParsedAssignment('Bob', 1.0, TaskAssignment::STATUS_GESTARTET, 'FFFFFF00'),
  75. ]),
  76. new ParsedTask('Review thing', 'Carol', 2, [
  77. new ParsedAssignment('Alice', 0.5, TaskAssignment::STATUS_ZUGEWIESEN, null),
  78. ]),
  79. ],
  80. reserveFraction: 0.2,
  81. inferredStartDate: '2026-03-23',
  82. inferredEndDate: '2026-04-05',
  83. warnings: [],
  84. );
  85. }
  86. public function testCreateNewSprintWritesEverythingTransactionally(): void
  87. {
  88. [$pdo, $importer, $req] = $this->build();
  89. $sheet = $this->sampleSheet();
  90. $result = $importer->commit(
  91. sheet: $sheet,
  92. sprintName: 'Sprint 1',
  93. startDate: '2026-03-23',
  94. endDate: '2026-04-05',
  95. target: 'new',
  96. existingSprintId: null,
  97. req: $req,
  98. actor: null,
  99. );
  100. $this->assertSame('Sprint 1', $result->sprintName);
  101. $this->assertSame(2, $result->weekCount);
  102. $this->assertSame(2, $result->workerCount);
  103. $this->assertSame(2, $result->taskCount);
  104. // The two parsed workers were both auto-created.
  105. $this->assertSame(['Alice', 'Bob'], $result->createdWorkers);
  106. // Carol (the second task's owner) doesn't exist as a worker — recorded as missing.
  107. $this->assertSame(['Carol'], $result->missingOwners);
  108. // Sprint row + reserve.
  109. $sprintRow = $pdo->query('SELECT * FROM sprints')->fetch();
  110. $this->assertSame('Sprint 1', $sprintRow['name']);
  111. $this->assertSame('2026-03-23', $sprintRow['start_date']);
  112. $this->assertEqualsWithDelta(0.2, (float) $sprintRow['reserve_fraction'], 1e-9);
  113. // 2 weeks with active_days_mask matching maxDays.
  114. $weeks = $pdo->query('SELECT * FROM sprint_weeks ORDER BY sort_order')->fetchAll();
  115. $this->assertCount(2, $weeks);
  116. $this->assertSame(0b00011, (int) $weeks[0]['active_days_mask'], '2 days → Mo+Di');
  117. $this->assertSame(0b01111, (int) $weeks[1]['active_days_mask'], '4 days → Mo..Do');
  118. $this->assertEqualsWithDelta(2.0, (float) $weeks[0]['max_working_days'], 1e-9);
  119. $this->assertEqualsWithDelta(4.0, (float) $weeks[1]['max_working_days'], 1e-9);
  120. // 2 sprint_workers with RTBs.
  121. $sw = $pdo->query('SELECT sw.*, w.name FROM sprint_workers sw JOIN workers w ON w.id=sw.worker_id ORDER BY sw.sort_order')->fetchAll();
  122. $this->assertCount(2, $sw);
  123. $this->assertSame('Alice', $sw[0]['name']);
  124. $this->assertEqualsWithDelta(0.5, (float) $sw[0]['rtb'], 1e-9);
  125. $this->assertSame('Bob', $sw[1]['name']);
  126. $this->assertEqualsWithDelta(0.7, (float) $sw[1]['rtb'], 1e-9);
  127. // sprint_worker_days: 3 non-zero cells (Alice w1+w2, Bob w1; Bob w2 was 0).
  128. $dayCells = (int) $pdo->query('SELECT COUNT(*) FROM sprint_worker_days')->fetchColumn();
  129. $this->assertSame(3, $dayCells);
  130. // 2 tasks; first owned by Alice, second has no owner (Carol unknown).
  131. $tasks = $pdo->query('SELECT * FROM tasks ORDER BY sort_order')->fetchAll();
  132. $this->assertCount(2, $tasks);
  133. $this->assertSame('Build thing', $tasks[0]['title']);
  134. $this->assertNotNull($tasks[0]['owner_worker_id']);
  135. $this->assertSame('Review thing', $tasks[1]['title']);
  136. $this->assertNull($tasks[1]['owner_worker_id']);
  137. $this->assertSame(1, (int) $tasks[0]['priority']);
  138. $this->assertSame(2, (int) $tasks[1]['priority']);
  139. // Task assignments: 2 days+colour for task 1, none for task 2 (the
  140. // single ZUGEWIESEN cell with 0.5 days writes a row but no status row).
  141. $aRows = $pdo->query('SELECT ta.*, t.title FROM task_assignments ta JOIN tasks t ON t.id=ta.task_id ORDER BY t.sort_order, ta.sprint_worker_id')->fetchAll();
  142. $this->assertCount(3, $aRows, 'three assignment rows total');
  143. // Status: the green cell → abgeschlossen, the yellow cell → gestartet,
  144. // the default zugewiesen cell stays at default.
  145. $statuses = array_map(fn($r) => $r['status'], $aRows);
  146. sort($statuses);
  147. $this->assertSame(['abgeschlossen', 'gestartet', 'zugewiesen'], $statuses);
  148. // Audit log: sprint CREATE + IMPORTED_FROM_XLSX + 2× sprint_week CREATE
  149. // + 2× worker CREATE + 2× sprint_worker CREATE + 3× sprint_worker_day CREATE
  150. // + 2× task CREATE + 3× task_assignment CREATE + 2× task_assignment status UPDATE/CREATE.
  151. $auditCount = (int) $pdo->query('SELECT COUNT(*) FROM audit_log')->fetchColumn();
  152. $this->assertGreaterThan(15, $auditCount, 'every write is audited');
  153. $importedRows = (int) $pdo->query("SELECT COUNT(*) FROM audit_log WHERE action='IMPORTED_FROM_XLSX'")->fetchColumn();
  154. $this->assertSame(1, $importedRows);
  155. }
  156. public function testRefusesToImportIntoNonEmptyExistingSprint(): void
  157. {
  158. [$pdo, $importer, $req] = $this->build();
  159. $sprints = new SprintRepository($pdo);
  160. $sprint = $sprints->create('Existing', '2026-03-23', '2026-04-05', 0.2);
  161. $sprints->materializeWeeks($sprint->id, '2026-03-23', 2); // populates sprint_weeks
  162. $this->expectException(RuntimeException::class);
  163. $this->expectExceptionMessage('not empty');
  164. $importer->commit(
  165. sheet: $this->sampleSheet(),
  166. sprintName: 'Existing',
  167. startDate: '2026-03-23',
  168. endDate: '2026-04-05',
  169. target: 'existing',
  170. existingSprintId: $sprint->id,
  171. req: $req,
  172. actor: null,
  173. );
  174. }
  175. public function testReusesExistingWorkerByCaseFoldedName(): void
  176. {
  177. [$pdo, $importer, $req] = $this->build();
  178. // Pre-create Alice with non-matching case + extra spaces.
  179. (new WorkerRepository($pdo))->create(' alice ', true, 0.0);
  180. $result = $importer->commit(
  181. sheet: $this->sampleSheet(),
  182. sprintName: 'S',
  183. startDate: '2026-03-23',
  184. endDate: '2026-04-05',
  185. target: 'new',
  186. existingSprintId: null,
  187. req: $req,
  188. actor: null,
  189. );
  190. // Bob is created; Alice already existed (matched by case-folded name).
  191. $this->assertSame(['Bob'], $result->createdWorkers);
  192. $this->assertSame(2, (int) $pdo->query('SELECT COUNT(*) FROM workers')->fetchColumn());
  193. }
  194. public function testMaxDaysToMaskSemantics(): void
  195. {
  196. $this->assertSame(0, SprintImporter::maxDaysToMask(0));
  197. $this->assertSame(0b00001, SprintImporter::maxDaysToMask(1));
  198. $this->assertSame(0b00011, SprintImporter::maxDaysToMask(2));
  199. $this->assertSame(0b00111, SprintImporter::maxDaysToMask(3));
  200. $this->assertSame(0b01111, SprintImporter::maxDaysToMask(4));
  201. $this->assertSame(0b11111, SprintImporter::maxDaysToMask(5));
  202. $this->assertSame(0b11111, SprintImporter::maxDaysToMask(7), 'caps at 5');
  203. $this->assertSame(0, SprintImporter::maxDaysToMask(-1));
  204. }
  205. public function testFoldNormalisation(): void
  206. {
  207. $this->assertSame('alice', SprintImporter::fold('Alice'));
  208. $this->assertSame('alice', SprintImporter::fold(' ALICE '));
  209. $this->assertSame('michael br', SprintImporter::fold('Michael Br'));
  210. $this->assertSame('jürg', SprintImporter::fold('Jürg'));
  211. }
  212. }