fix assessment import and exams draft
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 51s
Tests / PHPUnit (push) Failing after 1m26s

This commit is contained in:
root
2026-09-12 02:39:47 -04:00
parent 4b2dd4bdf8
commit ce504c933e
7 changed files with 1397 additions and 109 deletions
@@ -0,0 +1,188 @@
<?php
namespace Tests\App\Controllers\View;
use App\Controllers\View\ExamDraftController;
use CodeIgniter\Test\CIUnitTestCase;
use ReflectionClass;
use ReflectionMethod;
final class ExamDraftControllerGroupingTest extends CIUnitTestCase
{
public function testLatestReviewedRevisionControlsStatusAndFinalFile(): void
{
$rows = [
$this->examRow([
'id' => 12,
'status' => 'accepted',
'review_revision' => 2,
'acceptance_type' => 'minor_edits',
'reviewed_at' => '2026-05-02 12:00:00',
'final_file' => 'final-v2.docx',
'final_filename' => 'Grade 3 Final.docx',
'final_pdf_file' => 'final-v2.pdf',
]),
$this->examRow([
'id' => 11,
'status' => 'under review',
'review_revision' => 1,
'reviewed_at' => '2026-05-01 12:00:00',
'final_file' => 'review-v1.docx',
]),
$this->examRow([
'id' => 10,
'status' => 'submitted',
'review_revision' => 0,
'teacher_file' => 'teacher-v1.docx',
]),
];
$grouped = $this->invoke('groupReviewRevisions', [$rows]);
$this->assertCount(1, $grouped);
$this->assertSame('accepted', $grouped[0]['status']);
$this->assertSame(2, $grouped[0]['review_revision']);
$this->assertSame('minor_edits', $grouped[0]['acceptance_type']);
$this->assertSame('final-v2.docx', $grouped[0]['final_file']);
$this->assertSame('final-v2.pdf', $grouped[0]['final_pdf_file']);
$this->assertCount(2, $grouped[0]['review_files']);
}
public function testGradeArchiveCombinesLegacyCopiesAcrossSections(): void
{
$rows = [
$this->examRow([
'id' => 1,
'class_id' => 3,
'class_name' => '3',
'class_section_id' => 30,
'class_section_name' => '3',
'school_year' => '2024-2025',
'status' => 'legacy',
'final_file' => 'grade-3-final.pdf',
]),
$this->examRow([
'id' => 2,
'class_id' => 3,
'class_name' => '3',
'class_section_id' => 31,
'class_section_name' => '3-A',
'school_year' => '2024-2025',
'status' => 'legacy',
'final_file' => 'grade-3-final.pdf',
]),
];
$groups = $this->invoke('groupArchivedExamsByGrade', [$rows]);
$this->assertArrayHasKey(3, $groups);
$this->assertSame('Grade 3', $groups[3]['class_section_name']);
$this->assertCount(1, $groups[3]['items']);
$this->assertSame(['3', '3-A'], $groups[3]['items'][0]['class_section_names']);
}
public function testSubmissionsAreScopedToYearWhileAcceptedRecordsAreAlsoArchived(): void
{
$rows = [
$this->examRow(['id' => 1, 'school_year' => '2025-2026', 'status' => 'submitted']),
$this->examRow(['id' => 2, 'school_year' => '2024-2025', 'status' => 'under review']),
$this->examRow(['id' => 3, 'school_year' => '2025-2026', 'status' => 'accepted']),
$this->examRow(['id' => 4, 'school_year' => '2024-2025', 'status' => 'legacy']),
];
[$drafts, $archived] = $this->invoke('partitionExamDraftsByYear', [$rows, '2025-2026']);
$this->assertSame([1, 3], array_column($drafts, 'id'));
$this->assertSame([3, 4], array_column($archived, 'id'));
}
public function testFinalArchiveKeepsOnlyLatestAcceptedTeacherVersion(): void
{
$rows = [
$this->examRow(['id' => 10, 'status' => 'accepted', 'version' => 1, 'review_revision' => 3]),
$this->examRow(['id' => 11, 'status' => 'accepted', 'version' => 2, 'review_revision' => 1]),
$this->examRow(['id' => 12, 'status' => 'legacy', 'version' => 1]),
];
$archive = $this->invoke('latestFinalArchiveRows', [$rows]);
$this->assertSame([12, 11], array_column($archive, 'id'));
}
public function testFinalArchiveLinksArePdfOnly(): void
{
$view = file_get_contents(ROOTPATH . 'app/Views/administrator/exam_drafts.php') ?: '';
$this->assertStringContainsString("$" . "archivePdfFile = trim((string) ($" . "item['final_pdf_file']", $view);
$this->assertStringContainsString('View PDF', $view);
$this->assertStringContainsString('Download PDF', $view);
$this->assertStringContainsString('Upload replacement PDF', $view);
$this->assertStringContainsString('automatic conversion is unavailable', $view);
}
public function testLossyPhpWordPdfFallbackIsNotUsedForFinalArchive(): void
{
$controller = file_get_contents(ROOTPATH . 'app/Controllers/View/ExamDraftController.php') ?: '';
$this->assertStringNotContainsString('PDF_RENDERER_DOMPDF', $controller);
$this->assertStringContainsString('EXAM_PDF_CONVERTER_BINARY', $controller);
$this->assertStringContainsString('STIRLING_PDF_URL', $controller);
$this->assertStringContainsString('/api/v1/convert/file/pdf', $controller);
$this->assertStringContainsString('-env:UserInstallation=file://', $controller);
$this->assertStringNotContainsString('Legacy exams must be uploaded as PDF', $controller);
}
public function testGeneratedPdfBesideDocxIsRejectedAsLossy(): void
{
$directory = WRITEPATH . 'uploads/exams/finals';
$base = 'conversion-safety-' . uniqid('', true);
$pdf = $directory . '/' . $base . '.pdf';
$docx = $directory . '/' . $base . '.docx';
$archivePdf = $directory . '/' . $base . '.archive.pdf';
file_put_contents($pdf, '%PDF unsafe');
file_put_contents($docx, 'source');
file_put_contents($archivePdf, '%PDF safe');
try {
$this->assertFalse($this->invoke('isFormattingSafePdf', [basename($pdf)]));
$this->assertTrue($this->invoke('isFormattingSafePdf', [basename($archivePdf)]));
unlink($docx);
$this->assertTrue($this->invoke('isFormattingSafePdf', [basename($pdf)]));
} finally {
foreach ([$pdf, $docx, $archivePdf] as $path) {
if (is_file($path)) {
unlink($path);
}
}
}
}
/** @return array<string,mixed> */
private function examRow(array $overrides): array
{
return array_merge([
'id' => 1,
'teacher_id' => 20,
'class_section_id' => 30,
'class_section_name' => '3',
'semester' => 'Spring',
'school_year' => '2025-2026',
'exam_type' => 'Final Exam',
'version' => 1,
'review_revision' => 0,
'status' => 'submitted',
'final_file' => null,
'final_filename' => null,
'final_pdf_file' => null,
], $overrides);
}
private function invoke(string $method, array $arguments): mixed
{
$controller = (new ReflectionClass(ExamDraftController::class))->newInstanceWithoutConstructor();
$reflection = new ReflectionMethod($controller, $method);
$reflection->setAccessible(true);
return $reflection->invokeArgs($controller, $arguments);
}
}
@@ -0,0 +1,94 @@
<?php
namespace Tests\App\Libraries;
use App\Libraries\AssessmentDocxReader;
use CodeIgniter\Test\CIUnitTestCase;
use PhpOffice\PhpWord\PhpWord;
use PhpOffice\PhpWord\IOFactory;
final class AssessmentDocxReaderTest extends CIUnitTestCase
{
private const QUESTIONS = [
['id' => 11, 'text' => 'What is the students Islamic education background?'],
['id' => 12, 'text' => 'What is the students Arabic competency level?'],
['id' => 13, 'text' => 'Any other questions / concerns that the parents may have?'],
];
public function testMapsNumberedParagraphQuestionsAndMultilineAnswers(): void
{
$result = (new AssessmentDocxReader())->mapAnswers([
'Student intake form',
'1. What is the student\'s Islamic education background?',
'Two years at home.',
'Weekly mosque classes.',
'2) What is the students Arabic competency level? Beginner reader',
'3. Any other questions / concerns that the parents may have?',
'None.',
], self::QUESTIONS);
$this->assertSame("Two years at home.\nWeekly mosque classes.", $result['answers'][11]);
$this->assertSame('Beginner reader', $result['answers'][12]);
$this->assertSame('None.', $result['answers'][13]);
$this->assertSame([], $result['missing']);
}
public function testReadsParagraphsAndTableCellsFromDocx(): void
{
$phpWord = new PhpWord();
$section = $phpWord->addSection();
$section->addText(self::QUESTIONS[0]['text']);
$section->addText('Studied for one year.');
$table = $section->addTable();
$table->addRow();
$table->addCell()->addText(self::QUESTIONS[1]['text']);
$table->addCell()->addText('Conversational');
$path = tempnam(sys_get_temp_dir(), 'assessment-docx-');
$this->assertNotFalse($path);
$docxPath = $path . '.docx';
try {
IOFactory::createWriter($phpWord, 'Word2007')->save($docxPath);
$result = (new AssessmentDocxReader())->read($docxPath, array_slice(self::QUESTIONS, 0, 2));
} finally {
@unlink($path);
@unlink($docxPath);
}
$this->assertSame('Studied for one year.', $result['answers'][11]);
$this->assertSame('Conversational', $result['answers'][12]);
}
public function testReportsQuestionsWithoutAnswers(): void
{
$result = (new AssessmentDocxReader())->mapAnswers([
self::QUESTIONS[0]['text'],
'An answer.',
], self::QUESTIONS);
$this->assertSame([11 => 'An answer.'], $result['answers']);
$this->assertArrayHasKey(12, $result['missing']);
$this->assertArrayHasKey(13, $result['missing']);
}
public function testAllowsAnOmittedQuestionWordAndStopsAtAdministrativeNotes(): void
{
$questions = [
['id' => 21, 'text' => 'If surnames of enrolled students are different, please confirm if they are all siblings; if NOT, then please specify their relationship to each other:'],
self::QUESTIONS[2],
];
$result = (new AssessmentDocxReader())->mapAnswers([
'If surnames of enrolled students are different, please confirm if they are all siblings; if NOT, then please their relationship to each other:',
'N/A',
'Any other questions / concerns that the parents may have:',
'Question 1: Please focus on completing the curriculum.',
'Principal Notes:',
'This must not become part of the parent answer.',
], $questions);
$this->assertSame('N/A', $result['answers'][21]);
$this->assertSame('Question 1: Please focus on completing the curriculum.', $result['answers'][13]);
$this->assertStringNotContainsString('Principal Notes', $result['answers'][13]);
$this->assertSame([], $result['missing']);
}
}