1
0

XlsxSprintImporterTest.php 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  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\ParsedSheet;
  13. use App\Domain\TaskAssignment;
  14. use App\Services\Import\XlsxSprintImporter;
  15. use App\Tests\TestCase;
  16. /**
  17. * Phase 20 — parser smoke test against the real sample workbook
  18. * (doc/Tool_Sprint Planning.xlsx). Skipped when the host PHP lacks the
  19. * extensions PhpSpreadsheet needs (ext-dom, ext-zip etc.); the production
  20. * Docker image and standard CI runners both have them.
  21. */
  22. final class XlsxSprintImporterTest extends TestCase
  23. {
  24. private const FIXTURE = __DIR__ . '/../../../doc/Tool_Sprint Planning.xlsx';
  25. protected function setUp(): void
  26. {
  27. foreach (['dom', 'zip', 'xmlreader', 'simplexml', 'gd'] as $ext) {
  28. if (!extension_loaded($ext)) {
  29. $this->markTestSkipped("ext-{$ext} not loaded; PhpSpreadsheet cannot run on this host.");
  30. }
  31. }
  32. if (!is_file(self::FIXTURE)) {
  33. $this->markTestSkipped('Sample workbook not present at ' . self::FIXTURE);
  34. }
  35. }
  36. public function testParsesEverySheet(): void
  37. {
  38. $parser = new XlsxSprintImporter();
  39. $sheets = $parser->parse(self::FIXTURE);
  40. $this->assertCount(3, $sheets);
  41. $this->assertSame(['Sprint 1', 'Sprint 2', 'Sprint 3'], array_map(fn(ParsedSheet $s) => $s->sheetName, $sheets));
  42. }
  43. public function testSprint1ShapeAndCounts(): void
  44. {
  45. $parser = new XlsxSprintImporter();
  46. $sheets = $parser->parse(self::FIXTURE);
  47. $s = $sheets[0];
  48. $this->assertSame(5, count($s->weeks), '5 weeks');
  49. $this->assertSame(15, count($s->workers), '15 workers');
  50. $this->assertGreaterThan(20, count($s->tasks), 'more than 20 tasks');
  51. $this->assertEqualsWithDelta(0.2, $s->reserveFraction, 1e-9, 'reserve fraction = 0.2');
  52. $kws = array_map(fn($w) => $w->kw, $s->weeks);
  53. $this->assertSame([13, 14, 15, 16, 17], $kws, 'KWs 13..17 in order');
  54. $maxDays = array_map(fn($w) => $w->maxWorkingDays, $s->weeks);
  55. $this->assertSame([2, 4, 4, 5, 2], $maxDays);
  56. }
  57. public function testSprint2ColourMappingMatchesSpreadsheet(): void
  58. {
  59. $parser = new XlsxSprintImporter();
  60. $sheets = $parser->parse(self::FIXTURE);
  61. $s2 = $sheets[1];
  62. $this->assertSame('Sprint 2', $s2->sheetName);
  63. $statusCounts = [
  64. TaskAssignment::STATUS_ZUGEWIESEN => 0,
  65. TaskAssignment::STATUS_GESTARTET => 0,
  66. TaskAssignment::STATUS_ABGESCHLOSSEN => 0,
  67. TaskAssignment::STATUS_ABGEBROCHEN => 0,
  68. ];
  69. foreach ($s2->tasks as $t) {
  70. foreach ($t->assignments as $a) {
  71. $statusCounts[$a->status]++;
  72. }
  73. }
  74. // From the openpyxl colour audit on the sample:
  75. // 17× FFFFFF00 + 6× FFFFEB9C + 4× FFFFC000 = 27 yellow/orange -> gestartet
  76. // 4× FF00B050 + 1× FFC6EFCE = 5 green -> abgeschlossen
  77. // the only red-coded cells in the workbook are zero.
  78. $this->assertSame(27, $statusCounts[TaskAssignment::STATUS_GESTARTET], 'yellow + orange cells = 27');
  79. $this->assertSame(5, $statusCounts[TaskAssignment::STATUS_ABGESCHLOSSEN], 'green cells = 5');
  80. $this->assertSame(0, $statusCounts[TaskAssignment::STATUS_ABGEBROCHEN], 'no red cells in sample');
  81. }
  82. public function testSprint2SkipsArbeitstageGapAndDefinesSixteenWorkers(): void
  83. {
  84. // Sprint 2's Arbeitstage block has a blank row at C13 between Titus and
  85. // Suzan; the parser should resume past the gap and end up with 16 workers.
  86. $parser = new XlsxSprintImporter();
  87. $sheets = $parser->parse(self::FIXTURE);
  88. $s2 = $sheets[1];
  89. $this->assertCount(16, $s2->workers, 'Sprint 2 has 16 workers (gap row tolerated)');
  90. $names = array_map(fn($w) => $w->name, $s2->workers);
  91. $this->assertContains('Suzan', $names);
  92. $this->assertContains('Nicole', $names);
  93. }
  94. public function testRoundTripsViaToArrayFromArray(): void
  95. {
  96. $parser = new XlsxSprintImporter();
  97. $sheets = $parser->parse(self::FIXTURE);
  98. foreach ($sheets as $orig) {
  99. $clone = ParsedSheet::fromArray($orig->toArray());
  100. $this->assertSame($orig->sheetName, $clone->sheetName);
  101. $this->assertSame(count($orig->weeks), count($clone->weeks));
  102. $this->assertSame(count($orig->workers), count($clone->workers));
  103. $this->assertSame(count($orig->tasks), count($clone->tasks));
  104. $this->assertEqualsWithDelta($orig->reserveFraction, $clone->reserveFraction, 1e-9);
  105. }
  106. }
  107. }