From 9c22345d2dc61eb59f3fb2035c3f74619066ff33 Mon Sep 17 00:00:00 2001 From: Marijn Doeve Date: Wed, 1 Jul 2026 22:48:28 +0200 Subject: [PATCH] Fix quizToXlsx to support unlimited answers and add header count tests - Replace hardcoded 6-column arrays with dynamic Coordinate arithmetic - Write data rows first to determine max answer count, write headers last - Replace try/catch ErrorException in fillQuizFromArray with array_key_exists - Add data-provider test covering 2, 6, 7, and 10 answers - Add cross-question max-header and 7-answer round-trip tests --- src/Service/QuizSpreadsheetService.php | 65 +++++++------- tests/Service/QuizSpreadsheetServiceTest.php | 91 ++++++++++++++++++++ 2 files changed, 120 insertions(+), 36 deletions(-) diff --git a/src/Service/QuizSpreadsheetService.php b/src/Service/QuizSpreadsheetService.php index f2edfe8..93320ef 100644 --- a/src/Service/QuizSpreadsheetService.php +++ b/src/Service/QuizSpreadsheetService.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Tvdt\Service; +use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Reader; use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Writer; @@ -94,24 +95,16 @@ class QuizSpreadsheetService $answerCounter = 1; $arrCounter = 1; - while (true) { - try { - if (null === $questionArr[$arrCounter]) { - if (1 === $answerCounter) { - $errors[] = \sprintf('Question %d has no answers', $answerCounter); - } - - break; - } - } catch (\ErrorException) { - break; - } - + while (\array_key_exists($arrCounter, $questionArr) && null !== $questionArr[$arrCounter]) { $answer = new Answer((string) $questionArr[$arrCounter++], (bool) $questionArr[$arrCounter++]); $answer->ordering = $answerCounter++; $question->addAnswer($answer); } + if (1 === $answerCounter) { + $errors[] = \sprintf('Question %d has no answers', $answerCounter); + } + $quiz->addQuestion($question); } @@ -125,41 +118,41 @@ class QuizSpreadsheetService $spreadsheet = new Spreadsheet(); $sheet = $spreadsheet->getActiveSheet(); - $sheet->getStyle('1:1')->getFont()->setBold(true); - - $sheet->setCellValue('A1', 'Question'); - $sheet->getColumnDimension('A')->setWidth(30); - $sheet->getStyle('A:A')->getAlignment()->setWrapText(true); - - $counter = 1; - foreach (range('B', 'L', 2) as $column) { - $sheet->setCellValue($column.'1', 'Answer '.$counter++); - $sheet->getColumnDimension($column)->setWidth(30); - $sheet->getStyle($column.':'.$column)->getAlignment()->setWrapText(true); - } - - foreach (range('C', 'M', 2) as $column) { - $sheet->setCellValue($column.'1', 'Correct'); - $sheet->getColumnDimension($column)->setAutoSize(true); - } - - $answerColumns = range('B', 'L', 2); - $correctColumns = range('C', 'M', 2); - + // Write data rows first so we know the maximum answer count. + $maxAnswers = 0; $row = 2; foreach ($quiz->questions as $question) { $sheet->setCellValue('A'.$row, $question->question); $col = 0; foreach ($question->answers as $answer) { - $sheet->setCellValue($answerColumns[$col].$row, $answer->text); - $sheet->setCellValue($correctColumns[$col].$row, $answer->isRightAnswer); + $sheet->setCellValue(Coordinate::stringFromColumnIndex(2 + 2 * $col).$row, $answer->text); + $sheet->setCellValue(Coordinate::stringFromColumnIndex(3 + 2 * $col).$row, $answer->isRightAnswer); ++$col; } + $maxAnswers = max($maxAnswers, $col); ++$row; } + // Write headers last, sized to the widest question. + $sheet->getStyle('1:1')->getFont()->setBold(true); + $sheet->setCellValue('A1', 'Question'); + $sheet->getColumnDimension('A')->setWidth(30); + $sheet->getStyle('A:A')->getAlignment()->setWrapText(true); + + for ($i = 0; $i < $maxAnswers; ++$i) { + $answerCol = Coordinate::stringFromColumnIndex(2 + 2 * $i); + $correctCol = Coordinate::stringFromColumnIndex(3 + 2 * $i); + + $sheet->setCellValue($answerCol.'1', 'Answer '.($i + 1)); + $sheet->getColumnDimension($answerCol)->setWidth(30); + $sheet->getStyle($answerCol.':'.$answerCol)->getAlignment()->setWrapText(true); + + $sheet->setCellValue($correctCol.'1', 'Correct'); + $sheet->getColumnDimension($correctCol)->setAutoSize(true); + } + return $this->toXlsx($spreadsheet); } diff --git a/tests/Service/QuizSpreadsheetServiceTest.php b/tests/Service/QuizSpreadsheetServiceTest.php index 8f504c5..4766b25 100644 --- a/tests/Service/QuizSpreadsheetServiceTest.php +++ b/tests/Service/QuizSpreadsheetServiceTest.php @@ -4,7 +4,9 @@ declare(strict_types=1); namespace Tvdt\Tests\Service; +use PhpOffice\PhpSpreadsheet\Reader; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\File\File; use Tvdt\Entity\Answer; @@ -140,6 +142,65 @@ final class QuizSpreadsheetServiceTest extends TestCase } } + /** @return array */ + public static function answerCountHeaderProvider(): array + { + // Columns (0-based): Question=0, Answer1=1, Correct=2, Answer2=3, Correct=4, … + // Answer N is at index 1+2*(N-1) = 2N-1, Correct N at 2+2*(N-1) = 2N. + return [ + '2 answers → 2 header pairs' => [2, 'Answer 2', 3, 'Correct', 4, 5], + '6 answers → 6 header pairs' => [6, 'Answer 6', 11, 'Correct', 12, 13], + '7 answers → 7 header pairs' => [7, 'Answer 7', 13, 'Correct', 14, 15], + '10 answers → 10 header pairs' => [10, 'Answer 10', 19, 'Correct', 20, 21], + ]; + } + + #[DataProvider('answerCountHeaderProvider')] + public function testQuizToXlsxHeaderCountMatchesAnswerCount( + int $answerCount, + string $lastAnswerHeader, + int $lastAnswerIndex, + string $lastCorrectHeader, + int $lastCorrectIndex, + int $absentIndex, + ): void { + $path = $this->captureXlsx($this->subject->quizToXlsx($this->makeQuizWithAnswerCounts($answerCount))); + $headers = $this->readFirstRow($path); + + $this->assertSame($lastAnswerHeader, $headers[$lastAnswerIndex]); + $this->assertSame($lastCorrectHeader, $headers[$lastCorrectIndex]); + $this->assertArrayNotHasKey($absentIndex, $headers); + } + + public function testQuizToXlsxHeadersMatchMaxAnswersAcrossQuestions(): void + { + $quiz = new Quiz(); + $quiz->addQuestion($this->makeQuestion('Short', 3)); + $quiz->addQuestion($this->makeQuestion('Long', 7)); + $quiz->addQuestion($this->makeQuestion('Medium', 5)); + + $path = $this->captureXlsx($this->subject->quizToXlsx($quiz)); + $headers = $this->readFirstRow($path); + + $this->assertSame('Answer 7', $headers[13]); + $this->assertSame('Correct', $headers[14]); + $this->assertArrayNotHasKey(15, $headers); + } + + public function testQuizToXlsxRoundTripWithSevenAnswers(): void + { + $original = $this->makeQuizWithAnswerCounts(7); + $path = $this->captureXlsx($this->subject->quizToXlsx($original)); + + $imported = new Quiz(); + $this->subject->xlsxToQuiz($imported, new File($path)); + + $this->assertCount(1, $imported->questions); + /** @var Question $question */ + $question = $imported->questions->first(); + $this->assertCount(7, $question->answers); + } + private function makeQuiz(): Quiz { $quiz = new Quiz(); @@ -163,6 +224,36 @@ final class QuizSpreadsheetServiceTest extends TestCase return $quiz; } + private function makeQuizWithAnswerCounts(int ...$counts): Quiz + { + $quiz = new Quiz(); + foreach ($counts as $i => $count) { + $quiz->addQuestion($this->makeQuestion("Question $i", $count)); + } + + return $quiz; + } + + private function makeQuestion(string $text, int $answerCount): Question + { + $question = new Question(); + $question->question = $text; + $question->ordering = 1; + for ($i = 1; $i <= $answerCount; ++$i) { + $question->addAnswer(new Answer("Answer $i", isRightAnswer: false)); + } + + return $question; + } + + /** @return array */ + private function readFirstRow(string $path): array + { + $rows = (new Reader\Xlsx())->setReadDataOnly(true)->load($path)->getActiveSheet()->toArray(formatData: false); + + return $rows[0] ?? []; + } + private function captureXlsx(\Closure $closure): string { $path = $this->createTempPath('.xlsx');