SprintImporterCommitTest.php 10.0 KB

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