Harden published curriculum and student flows

This commit is contained in:
Hamza-Ayed
2026-09-30 08:22:17 +03:00
parent e8163fe265
commit ce7b0fcf14
53 changed files with 4723 additions and 3317 deletions
+50 -147
View File
@@ -3,169 +3,72 @@
declare(strict_types=1);
/**
* Links existing and ministry Grade 10 Math videos to teacher_submissions and video_versions
* so that they appear in the curriculum tree with has_video = true and stream in the student app.
* Read-only audit for historic Grade 10 Math video records.
*
* Usage:
* php backend/scripts/link_legacy_math_videos.php [--apply]
* This replaces the retired linker, which hard-coded a Bunny hostname and
* marked legacy records published without version-bound video review evidence.
* New media must go through TeacherSubmissionService and the full review flow.
*
* Usage: php backend/scripts/link_legacy_math_videos.php
*/
require_once dirname(__DIR__) . '/app/bootstrap.php';
use App\Core\Database;
use App\Services\CurriculumService;
$apply = in_array('--apply', $argv, true);
echo "=== Grade 10 Math Video Linking & Activation ===\n";
// 1. Resolve master system course
$courseId = CurriculumService::getOrCreateSystemCourse();
echo "System Course ID: {$courseId}\n";
// 2. Resolve teacher ID
$teacher = Database::selectOne("SELECT id, full_name FROM teachers WHERE id = 1 LIMIT 1");
if (!$teacher) {
$teacher = Database::selectOne("SELECT id, full_name FROM teachers LIMIT 1");
if (in_array('--apply', $argv, true)) {
fwrite(STDERR, "This legacy linker is retired and read-only. Use the teacher upload and review workflow.\n");
exit(2);
}
if (!$teacher) {
echo "ERROR: No teacher found in database.\n";
exit(1);
}
$teacherId = (int)$teacher['id'];
echo "Publishing Teacher: {$teacher['full_name']} (ID: {$teacherId})\n";
// 3. Find Grade 10 Math Unit 1 Lessons in curriculum_lessons
$mathLessons = Database::select(
"SELECT id, uuid, lesson_key, unit_key, title, source_manifest_path
FROM curriculum_lessons
WHERE grade_key = 'grade_10' AND subject_key = 'math_10' AND unit_key = 'unit_01'
ORDER BY lesson_key"
echo "=== Grade 10 Math legacy video audit (read-only) ===\n";
$lessons = Database::select(
"SELECT id, uuid, lesson_key, unit_key, title, source_manifest_path, curriculum_version
FROM curriculum_lessons
WHERE grade_key='grade_10' AND subject_key='math_10' AND unit_key='unit_01'
ORDER BY lesson_key, id"
);
if (empty($mathLessons)) {
echo "No Grade 10 Math Unit 1 lessons found in curriculum_lessons.\n";
exit(1);
}
foreach ($lessons as $curriculumLesson) {
$path = (string)$curriculumLesson['source_manifest_path'];
$pathWithoutExtension = preg_replace('/\.md$/i', '', $path);
$records = Database::select(
"SELECT l.id, l.title, l.curriculum_key, l.storage_type, l.duration_seconds,
l.encoding_status, l.hls_url, l.r2_url,
vv.uuid AS version_uuid, vv.status AS version_status,
ts.status AS submission_status, j.status AS review_status
FROM lessons l
LEFT JOIN video_versions vv ON vv.source_lesson_id=l.id
LEFT JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id
LEFT JOIN video_review_jobs j ON j.video_version_id=vv.id
WHERE l.curriculum_key IN (?, ?)
ORDER BY l.id, vv.id",
[$path, $pathWithoutExtension]
);
echo "Found " . count($mathLessons) . " Unit 1 Math lessons in curriculum_lessons.\n";
// Ministry / Certified lesson video streams (Bunny Stream / Cloudflare R2 standard HLS)
$knownStreams = [
'lesson_01' => [
'title' => 'الدرس الأول: حل نظام مكون من معادلة خطية ومعادلة تربيعية',
'hls_url' => 'https://saqel.b-cdn.net/hls/grade10_math_u1_l1/playlist.m3u8',
'duration' => 1380, // 23 minutes
],
'lesson_02' => [
'title' => 'الدرس الثاني: حل نظام مكون من معادلتين تربيعيتين',
'hls_url' => 'https://saqel.b-cdn.net/hls/grade10_math_u1_l2/playlist.m3u8',
'duration' => 1440, // 24 minutes
],
];
foreach ($mathLessons as $cl) {
$lessonKey = $cl['lesson_key'];
if (!isset($knownStreams[$lessonKey])) {
echo "\n{$path} [{$curriculumLesson['curriculum_version']}]\n";
if ($records === []) {
echo " no matching video records\n";
continue;
}
$streamInfo = $knownStreams[$lessonKey];
echo "\nProcessing: [{$lessonKey}] {$cl['title']}\n";
// Check if a record already exists in lessons table
$existingLesson = Database::selectOne(
"SELECT id, title, hls_url, encoding_status FROM lessons WHERE curriculum_key = ? OR title LIKE ? LIMIT 1",
[$cl['source_manifest_path'], '%' . $streamInfo['title'] . '%']
);
$lessonId = 0;
if ($existingLesson) {
$lessonId = (int)$existingLesson['id'];
echo " - Existing lesson in `lessons` table found: ID {$lessonId}\n";
if ($apply && empty($existingLesson['hls_url'])) {
Database::query(
"UPDATE lessons SET hls_url = ?, encoding_status = 'ready', duration_seconds = ? WHERE id = ?",
[$streamInfo['hls_url'], $streamInfo['duration'], $lessonId]
);
}
} else {
echo " - Creating entry in `lessons` table...\n";
if ($apply) {
$vUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000, mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff));
$lessonId = (int)Database::insert(
"INSERT INTO lessons (course_id, title, curriculum_key, sequence_order, video_uuid, storage_type, hls_url, encoding_status, duration_seconds)
VALUES (?, ?, ?, 1, ?, 'bunny_stream', ?, 'ready', ?)",
[$courseId, $cl['title'], $cl['source_manifest_path'], $vUuid, $streamInfo['hls_url'], $streamInfo['duration']]
);
echo " - Created lesson ID: {$lessonId}\n";
}
}
// Check teacher_submissions
$sub = Database::selectOne(
"SELECT id, uuid, current_published_video_version_id, status FROM teacher_submissions WHERE teacher_id = ? AND curriculum_lesson_id = ? LIMIT 1",
[$teacherId, $cl['id']]
);
$subId = 0;
if ($sub) {
$subId = (int)$sub['id'];
echo " - Existing submission found: ID {$subId} (Status: {$sub['status']})\n";
if ($apply && $sub['status'] !== 'published') {
Database::query("UPDATE teacher_submissions SET status = 'published' WHERE id = ?", [$subId]);
}
} else {
echo " - Creating teacher_submission...\n";
if ($apply) {
$sUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000, mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff));
$subId = (int)Database::insert(
"INSERT INTO teacher_submissions (uuid, teacher_id, curriculum_lesson_id, status) VALUES (?, ?, ?, 'published')",
[$sUuid, $teacherId, $cl['id']]
);
echo " - Created submission ID: {$subId}\n";
}
}
// Check video_versions
if ($subId > 0 && $lessonId > 0) {
$vv = Database::selectOne(
"SELECT id, uuid, status FROM video_versions WHERE teacher_submission_id = ? AND source_lesson_id = ? LIMIT 1",
[$subId, $lessonId]
foreach ($records as $record) {
$url = (string)($record['hls_url'] ?: $record['r2_url'] ?: '');
$host = $url !== '' ? (string)(parse_url($url, PHP_URL_HOST) ?: 'relative') : 'none';
$storage = str_ends_with($host, '.r2.dev') ? 'cloudflare_r2' : $record['storage_type'];
echo sprintf(
" lesson=%d storage=%s duration=%ss encoding=%s submission=%s version=%s review=%s host=%s\n",
(int)$record['id'],
$storage,
(int)$record['duration_seconds'],
(string)$record['encoding_status'],
(string)($record['submission_status'] ?? 'none'),
(string)($record['version_status'] ?? 'none'),
(string)($record['review_status'] ?? 'missing'),
$host
);
$vvId = 0;
if ($vv) {
$vvId = (int)$vv['id'];
echo " - Existing video_version found: ID {$vvId} (Status: {$vv['status']})\n";
if ($apply && $vv['status'] !== 'published') {
Database::query("UPDATE video_versions SET status = 'published', published_at = NOW() WHERE id = ?", [$vvId]);
}
} else {
echo " - Creating video_version...\n";
if ($apply) {
$vvUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000, mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff));
$vvId = (int)Database::insert(
"INSERT INTO video_versions (uuid, teacher_submission_id, version_number, status, source_lesson_id, published_at)
VALUES (?, ?, 1, 'published', ?, NOW())",
[$vvUuid, $subId, $lessonId]
);
echo " - Created video_version ID: {$vvId}\n";
}
}
if ($apply && $vvId > 0) {
Database::query(
"UPDATE teacher_submissions SET current_published_video_version_id = ? WHERE id = ?",
[$vvId, $subId]
);
echo " - Updated submission current_published_video_version_id = {$vvId}\n";
}
}
}
if ($apply) {
echo "\n=== Successfully linked and activated Math Unit 1 videos! ===\n";
} else {
echo "\nDRY RUN complete. Run with --apply to execute writes.\n";
}
echo "\nNo records were changed.\n";
+68 -29
View File
@@ -5,12 +5,12 @@ declare(strict_types=1);
/**
* Publication manager for Grade 10 curriculum bundles and assets.
*
* Transitions lessons and their assets from `draft`/`unverified` to `published`/`approved`.
* Publishes bundles only after independent lesson and asset approval.
*
* Safety rules:
* - Default mode is a read-only dry run. It writes nothing unless `--apply` is present.
* - All database writes happen inside a single atomic transaction.
* - Only assets belonging to the selected curriculum lessons are updated.
* - This script never grants academic approval or clears rights.
*
* Usage:
* php backend/scripts/publish_grade10_bundle.php --status
@@ -93,7 +93,7 @@ if ($isPilot) {
'grade_10/math_10/semester_1/unit_01/lesson_01.md',
'grade_10/math_10/semester_1/unit_01/lesson_02.md',
'grade_10/math_10/semester_1/unit_01/lesson_03.md',
'grade_10/math_10/semester_1/unit_01/intro_and_project.md',
'grade_10/physics_10/semester_1/unit_02/lesson_03.md',
// Physics 10: Semester 1, Units 1 & 2
'grade_10/physics_10/semester_1/unit_01/lesson_01.md',
'grade_10/physics_10/semester_1/unit_01/lesson_02.md',
@@ -140,12 +140,16 @@ if (empty($targetLessons)) {
echo "No matching curriculum lessons found for the specified criteria.\n";
exit(0);
}
if ($isPilot && count($targetLessons) !== 12) {
fwrite(STDERR, "REFUSE: pilot must contain exactly 12 matching lessons; found " . count($targetLessons) . ".\n");
exit(2);
}
$lessonIds = array_column($targetLessons, 'id');
$idList = implode(',', array_map('intval', $lessonIds));
// 3. Plan summary
$targetAction = $isRevert ? 'REVERT TO DRAFT' : 'PUBLISH / APPROVE';
$targetAction = $isRevert ? 'WITHDRAW PUBLISHED BUNDLES' : 'PUBLISH REVIEWED BUNDLES';
echo sprintf("=== Grade 10 Publication Plan (%s) ===\n", $apply ? 'APPLY' : 'DRY-RUN');
echo sprintf("Action: %s\n", $targetAction);
echo sprintf("Matched Lessons: %d\n", count($targetLessons));
@@ -161,8 +165,60 @@ foreach ($targetLessons as $idx => $l) {
);
}
function publicationBlockers(array $targetLessons, bool $lock): array
{
$blockers = [];
foreach ($targetLessons as $lesson) {
$id = (int)$lesson['id'];
$path = $lesson['source_manifest_path'];
$detail = Database::selectOne(
'SELECT source_status, reviewed_by, reviewed_at FROM curriculum_lessons WHERE id = ?' . ($lock ? ' FOR UPDATE' : ''),
[$id]
);
if (!$detail || $detail['source_status'] !== 'approved' || empty($detail['reviewed_by']) || empty($detail['reviewed_at'])) {
$blockers[] = "{$path}: academic approval with reviewer and date is missing";
}
$bundles = Database::select(
'SELECT id, status FROM publication_bundles WHERE curriculum_lesson_id = ?' . ($lock ? ' FOR UPDATE' : ''),
[$id]
);
if (count($bundles) !== 1) {
$blockers[] = "{$path}: expected exactly one bundle; found " . count($bundles);
continue;
}
$bundle = $bundles[0];
if (!in_array($bundle['status'], ['review', 'published'], true)) {
$blockers[] = "{$path}: bundle must be in review before publication";
}
$assets = Database::select(
'SELECT a.id, a.asset_type, a.review_status, a.rights_status, a.source_reference, pba.role
FROM publication_bundle_assets pba JOIN content_assets a ON a.id = pba.content_asset_id
WHERE pba.publication_bundle_id = ?' . ($lock ? ' FOR UPDATE' : ''),
[(int)$bundle['id']]
);
if ($assets === []) {
$blockers[] = "{$path}: bundle has no assets";
}
$hasPrimary = false;
foreach ($assets as $asset) {
if ($asset['role'] === 'primary_lesson' && $asset['asset_type'] === 'lesson_markdown') $hasPrimary = true;
if ($asset['review_status'] !== 'approved' || $asset['rights_status'] !== 'cleared' || trim((string)$asset['source_reference']) === '') {
$blockers[] = "{$path}: asset {$asset['id']} lacks review, rights clearance, or source";
}
}
if (!$hasPrimary) $blockers[] = "{$path}: approved lesson Markdown is missing";
}
return $blockers;
}
if (!$apply && !$isRevert) {
$blockers = publicationBlockers($targetLessons, false);
foreach ($blockers as $blocker) echo "BLOCKED {$blocker}\n";
echo $blockers === [] ? "\n[Ready for reviewed publication; append --apply.]\n" : "\n[Publication blocked; resolve review evidence first.]\n";
exit($blockers === [] ? 0 : 2);
}
if (!$apply) {
echo "\n[Dry-run completed. To execute, append --apply to your command.]\n";
echo "\n[Dry-run completed. To withdraw bundles, append --apply.]\n";
exit(0);
}
@@ -172,34 +228,17 @@ $pdo->beginTransaction();
try {
if ($isRevert) {
// Revert to draft
// Withdrawal preserves academic decisions, rights records and history.
Database::query(
"UPDATE curriculum_lessons SET source_status = 'unverified' WHERE id IN ({$idList})"
);
Database::query(
"UPDATE publication_bundles SET status = 'draft', published_at = NULL WHERE curriculum_lesson_id IN ({$idList})"
);
Database::query(
"UPDATE content_assets a
JOIN publication_bundle_assets pba ON pba.content_asset_id = a.id
JOIN publication_bundles pb ON pb.id = pba.publication_bundle_id
SET a.review_status = 'draft', a.rights_status = 'review_required'
WHERE pb.curriculum_lesson_id IN ({$idList})"
"UPDATE publication_bundles SET status = 'withdrawn', withdrawn_at = CURRENT_TIMESTAMP WHERE curriculum_lesson_id IN ({$idList}) AND status = 'published'"
);
} else {
// Publish and approve
$blockers = publicationBlockers($targetLessons, true);
if ($blockers !== []) {
throw new RuntimeException("Publication blocked:\n" . implode("\n", $blockers));
}
Database::query(
"UPDATE curriculum_lessons SET source_status = 'approved', reviewed_at = CURRENT_TIMESTAMP WHERE id IN ({$idList})"
);
Database::query(
"UPDATE publication_bundles SET status = 'published', published_at = CURRENT_TIMESTAMP WHERE curriculum_lesson_id IN ({$idList})"
);
Database::query(
"UPDATE content_assets a
JOIN publication_bundle_assets pba ON pba.content_asset_id = a.id
JOIN publication_bundles pb ON pb.id = pba.publication_bundle_id
SET a.review_status = 'approved', a.rights_status = 'cleared'
WHERE pb.curriculum_lesson_id IN ({$idList})"
"UPDATE publication_bundles SET status = 'published', published_at = CURRENT_TIMESTAMP WHERE curriculum_lesson_id IN ({$idList}) AND status = 'review'"
);
}
+102 -147
View File
@@ -1,13 +1,10 @@
<?php
declare(strict_types=1);
/**
* Publishes Grade 10 textbook PDFs into content_assets and publication_bundle_assets
* so they appear in the student app under "الكتب المقررة" for each subject.
*
* Usage:
* php backend/scripts/publish_grade10_textbooks.php [--apply]
* Link previously reviewed Grade 10 textbook PDFs to published bundles.
* Never creates a published bundle, grants rights, or approves an asset.
* Usage: php backend/scripts/publish_grade10_textbooks.php [--apply]
*/
require_once dirname(__DIR__) . '/app/bootstrap.php';
@@ -15,165 +12,123 @@ require_once dirname(__DIR__) . '/app/bootstrap.php';
use App\Core\Database;
$apply = in_array('--apply', $argv, true);
$projectRoot = dirname(__DIR__, 2);
$booksRoot = $projectRoot . '/books';
$curriculumRoot = $projectRoot . '/backend/storage/curriculum';
echo "=== Publishing Grade 10 Textbooks ===\n";
$booksRoot = dirname(__DIR__, 2) . '/books';
$curriculumRoot = dirname(__DIR__) . '/storage/curriculum';
$subjectMap = [
'كيمياء' => 'chemistry_10', 'الرياضيات' => 'math_10', 'الفيزياء' => 'physics_10',
'الأحياء' => 'biology_10', 'حياتية' => 'biology_10', 'علوم الأرض' => 'earth_sciences_10',
'اللغة العربية' => 'arabic_10', 'العربية' => 'arabic_10',
'الإنجليزية' => 'english_10', 'الانجليزية' => 'english_10',
'التربية الإسلامية' => 'islamic_10', 'الإسلامية' => 'islamic_10',
'تاريخ' => 'history_10', 'جغرافيا' => 'geography_10',
'الوطنية' => 'civics_10', 'المالية' => 'financial_10',
'الرقمية' => 'digital_skills_10', 'حاسوب' => 'digital_skills_10',
];
if (!is_dir($booksRoot)) {
echo "Books directory not found at {$booksRoot}\n";
fwrite(STDERR, "Books directory is missing.\n");
exit(1);
}
$pdfFiles = glob($booksRoot . '/*.pdf') ?: [];
echo "Found " . count($pdfFiles) . " textbook PDFs in {$booksRoot}.\n";
$subjectMap = [
'كيمياء' => 'chemistry_10',
'الرياضيات' => 'math_10',
'الفيزياء' => 'physics_10',
'الأحياء' => 'biology_10',
'حياتية' => 'biology_10',
'علوم الأرض' => 'earth_sciences_10',
'اللغة العربية' => 'arabic_10',
'العربية' => 'arabic_10',
'الإنجليزية' => 'english_10',
'الانجليزية' => 'english_10',
'التربية الإسلامية' => 'islamic_10',
'الإسلامية' => 'islamic_10',
'تاريخ' => 'history_10',
'جغرافيا' => 'geography_10',
'الوطنية' => 'civics_10',
'المالية' => 'financial_10',
'الرقمية' => 'digital_skills_10',
'حاسوب' => 'digital_skills_10',
];
$publishedCount = 0;
foreach ($pdfFiles as $filePath) {
$ready = [];
$blocked = [];
foreach (glob($booksRoot . '/*.pdf') ?: [] as $filePath) {
$filename = basename($filePath);
$sha256 = hash_file('sha256', $filePath);
$bytes = filesize($filePath);
// Identify subject
$matchedSubject = null;
foreach ($subjectMap as $keyword => $subKey) {
if (str_contains($filename, $keyword)) {
$matchedSubject = $subKey;
break;
}
}
if (!$matchedSubject) {
echo " - Skipping unclassified PDF: {$filename}\n";
if (!str_contains($filename, 'كتاب الطالب')) {
echo "SKIP {$filename}: not a student textbook\n";
continue;
}
$semesterKey = str_contains($filename, 'الثاني') ? 'semester_2' : 'semester_1';
echo "\nProcessing: [{$matchedSubject} - {$semesterKey}] {$filename}\n";
// Destination in curriculum storage
$relativeDest = "textbooks/grade_10/{$matchedSubject}/" . basename($filePath);
$fullDest = $curriculumRoot . '/' . $relativeDest;
if ($apply) {
$destDir = dirname($fullDest);
if (!is_dir($destDir)) {
mkdir($destDir, 0755, true);
}
if (!file_exists($fullDest) || hash_file('sha256', $fullDest) !== $sha256) {
copy($filePath, $fullDest);
}
$subject = null;
foreach ($subjectMap as $keyword => $key) {
if (str_contains($filename, $keyword)) { $subject = $key; break; }
}
// Find the representative published bundle for this subject
$lesson = Database::selectOne(
"SELECT cl.id, cl.uuid FROM curriculum_lessons cl
JOIN publication_bundles pb ON pb.curriculum_lesson_id = cl.id
WHERE cl.grade_key = 'grade_10' AND cl.subject_key = ? AND cl.semester_key = ?
ORDER BY cl.unit_key ASC, cl.lesson_key ASC LIMIT 1",
[$matchedSubject, $semesterKey]
);
if (!$lesson) {
// Fallback: any lesson in the subject
$lesson = Database::selectOne(
"SELECT cl.id, cl.uuid FROM curriculum_lessons cl
WHERE cl.grade_key = 'grade_10' AND cl.subject_key = ?
ORDER BY cl.id ASC LIMIT 1",
[$matchedSubject]
);
}
if (!$lesson) {
echo " - No curriculum lesson found for {$matchedSubject}.\n";
if ($subject === null) { $blocked[] = "{$filename}: unknown subject"; continue; }
if (str_contains($filename, 'الفصل الثاني')) {
$semester = 'semester_2';
} elseif (str_contains($filename, 'الفصل الأول')) {
$semester = 'semester_1';
} else {
$blocked[] = "{$filename}: explicit semester is missing";
continue;
}
$sha = hash_file('sha256', $filePath);
if ($sha === false) { $blocked[] = "{$filename}: unreadable source"; continue; }
$lessonId = (int)$lesson['id'];
// Ensure publication bundle exists
$bundle = Database::selectOne(
"SELECT id FROM publication_bundles WHERE curriculum_lesson_id = ? AND status = 'published' LIMIT 1",
[$lessonId]
);
$bundleId = 0;
if ($bundle) {
$bundleId = (int)$bundle['id'];
} elseif ($apply) {
$bUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000, mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff));
$bundleId = (int)Database::insert(
"INSERT INTO publication_bundles (uuid, curriculum_lesson_id, bundle_version, status, published_at)
VALUES (?, ?, 'grade10-intake-2026-09-10', 'published', NOW())",
[$bUuid, $lessonId]
);
}
// Ensure content_asset exists
$asset = Database::selectOne(
"SELECT id, uuid FROM content_assets WHERE sha256 = ? LIMIT 1",
[$sha256]
"SELECT id, storage_driver, storage_key, sha256, review_status, rights_status, source_reference
FROM content_assets WHERE sha256 = ? AND asset_type = 'textbook_pdf' LIMIT 1",
[$sha]
);
$assetId = 0;
if ($asset) {
$assetId = (int)$asset['id'];
echo " - Asset exists (ID: {$assetId})\n";
if ($apply) {
Database::query(
"UPDATE content_assets SET review_status = 'approved', rights_status = 'cleared', storage_key = ? WHERE id = ?",
[$relativeDest, $assetId]
);
}
} elseif ($apply) {
$aUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000, mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff));
$assetId = (int)Database::insert(
"INSERT INTO content_assets
(uuid, asset_type, storage_driver, storage_key, mime_type, byte_size, sha256, rights_status, review_status)
VALUES (?, 'textbook_pdf', 'local', ?, 'application/pdf', ?, ?, 'cleared', 'approved')",
[$aUuid, $relativeDest, $bytes, $sha256]
);
echo " - Created content_asset ID {$assetId}\n";
if (!$asset || $asset['review_status'] !== 'approved' || $asset['rights_status'] !== 'cleared' ||
trim((string)$asset['source_reference']) === '') {
$blocked[] = "{$filename}: reviewed textbook asset and rights evidence are missing";
continue;
}
if ($asset['storage_driver'] !== 'local') {
$blocked[] = "{$filename}: verify the remote asset with its storage provider before linking";
continue;
}
$storagePath = realpath($curriculumRoot . '/' . $asset['storage_key']);
$rootPath = realpath($curriculumRoot);
if ($storagePath === false || $rootPath === false || !str_starts_with($storagePath, $rootPath . DIRECTORY_SEPARATOR) ||
!hash_equals($sha, (string)hash_file('sha256', $storagePath))) {
$blocked[] = "{$filename}: approved stored file is missing or its checksum differs";
continue;
}
$bundle = Database::selectOne(
"SELECT pb.id FROM publication_bundles pb
JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id
WHERE cl.grade_key = 'grade_10' AND cl.subject_key = ? AND cl.semester_key = ?
AND cl.source_status = 'approved' AND pb.status = 'published'
ORDER BY cl.unit_key, cl.lesson_key, pb.id LIMIT 1",
[$subject, $semester]
);
if (!$bundle) {
$blocked[] = "{$filename}: no published bundle for {$subject}/{$semester}";
continue;
}
$ready[] = ['name' => $filename, 'bundle_id' => (int)$bundle['id'], 'asset_id' => (int)$asset['id']];
}
// Link in publication_bundle_assets
if ($apply && $bundleId > 0 && $assetId > 0) {
foreach ($blocked as $reason) echo "BLOCKED {$reason}\n";
foreach ($ready as $row) echo "READY {$row['name']} => bundle {$row['bundle_id']}\n";
if (!$apply) {
echo "DRY RUN: " . count($ready) . " ready, " . count($blocked) . " blocked.\n";
exit($blocked === [] ? 0 : 2);
}
if ($blocked !== []) {
fwrite(STDERR, "REFUSE: resolve all textbook review and matching blockers before --apply.\n");
exit(2);
}
if ($ready === []) {
fwrite(STDERR, "REFUSE: no reviewed textbook to link.\n");
exit(2);
}
$pdo = Database::getConnection();
$pdo->beginTransaction();
try {
foreach ($ready as $row) {
$current = Database::selectOne(
"SELECT pb.status, cl.source_status, a.review_status, a.rights_status
FROM publication_bundles pb JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id
JOIN content_assets a ON a.id = ? WHERE pb.id = ? FOR UPDATE",
[$row['asset_id'], $row['bundle_id']]
);
if (!$current || $current['status'] !== 'published' || $current['source_status'] !== 'approved' ||
$current['review_status'] !== 'approved' || $current['rights_status'] !== 'cleared') {
throw new RuntimeException('Approval changed while linking textbooks.');
}
Database::query(
"INSERT INTO publication_bundle_assets (publication_bundle_id, content_asset_id, role, sort_order)
VALUES (?, ?, 'textbook', 1)
ON DUPLICATE KEY UPDATE role = 'textbook'",
[$bundleId, $assetId]
VALUES (?, ?, 'textbook', 1) ON DUPLICATE KEY UPDATE sort_order = VALUES(sort_order)",
[$row['bundle_id'], $row['asset_id']]
);
echo " - Linked to publication bundle {$bundleId} as textbook.\n";
$publishedCount++;
}
}
if ($apply) {
echo "\n=== Successfully published {$publishedCount} textbooks! ===\n";
} else {
echo "\nDRY RUN complete. Run with --apply to execute.\n";
$pdo->commit();
echo "Linked " . count($ready) . " reviewed textbooks.\n";
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
fwrite(STDERR, "FAILED: {$e->getMessage()}\n");
exit(1);
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
// In-memory fixture only; no writes to repository, external database or network.
require_once dirname(__DIR__) . '/app/Core/Database.php';
require_once dirname(__DIR__) . '/app/Services/StudentAccessControlService.php';
require_once dirname(__DIR__) . '/app/Services/PublishedContentService.php';
require_once dirname(__DIR__) . '/app/Services/CurriculumService.php';
use App\Core\Database;
use App\Services\PublishedContentService;
use App\Services\CurriculumService;
$pdo = class_exists('Pdo\\Sqlite') ? new \Pdo\Sqlite('sqlite::memory:') : new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
(new ReflectionProperty(Database::class, 'instance'))->setValue(null, $pdo);
$pdo->exec('CREATE TABLE auth_identities (id INTEGER PRIMARY KEY, status TEXT)');
$pdo->exec('CREATE TABLE students (id INTEGER PRIMARY KEY, grade_level TEXT, identity_id INTEGER)');
$pdo->exec("INSERT INTO auth_identities VALUES (1, 'active'), (2, 'disabled')");
$pdo->exec("INSERT INTO students VALUES (7, 'grade_10', 1), (8, 'grade_9', 1), (10, 'grade_10', 2)");
function checkContent(bool $condition, string $message): void
{
if (!$condition) throw new RuntimeException($message);
}
$asset = ['grade_key' => 'grade_10', 'storage_driver' => 'local', 'storage_key' => 'manifest.json'];
checkContent(PublishedContentService::studentMayRead(7, $asset), 'Same-grade student denied');
checkContent(!PublishedContentService::studentMayRead(8, $asset), 'Other-grade student allowed');
checkContent(!PublishedContentService::studentMayRead(9, $asset), 'Unknown student allowed');
checkContent(!PublishedContentService::studentMayRead(10, $asset), 'Disabled identity allowed');
checkContent(PublishedContentService::readLocalAsset($asset) !== null, 'Exact in-root asset not found');
checkContent(PublishedContentService::readLocalAsset(array_merge($asset, ['storage_key' => '../../database_schema.sql'])) === null, 'Traversal escaped storage root');
checkContent(PublishedContentService::readLocalAsset(array_merge($asset, ['storage_key' => 'missing.md'])) === null, 'Missing file substituted');
checkContent(CurriculumService::getLessonMarkdown('../manifest.json') === '', 'Unsafe lesson read returned content');
checkContent(!CurriculumService::safeMarkdownPath('../grade_10/a.md'), 'Traversal path accepted');
checkContent(CurriculumService::safeMarkdownPath('grade_10/math_10/semester_1/unit_01/lesson_01.md'), 'Valid markdown path rejected');
echo "PASS 10 local content-access checks (not an HTTP/tenant test)\n";
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
// Pure in-memory checks; no database, files written, or external services.
require_once dirname(__DIR__) . '/app/Services/PublishedCurriculumTreeFilter.php';
use App\Services\PublishedCurriculumTreeFilter;
$manifest = [
'grade_10' => ['subjects' => [
'math_10' => [
'resources' => ['textbooks' => ['items' => [['file' => 'unpublished.pdf']]]],
'semesters' => ['semester_1' => ['units' => [
'unit_01' => ['lessons' => [
['id' => 'lesson_01', 'title' => 'عنوان أولي', 'file' => 'g10/math/u1/l1.md', 'outcomes' => ['غير مراجع']],
['id' => 'lesson_02', 'title' => 'غير منشور', 'file' => 'g10/math/u1/l2.md'],
]],
]]],
],
]],
'grade_11' => ['subjects' => [
'math_10' => ['semesters' => ['semester_1' => ['units' => [
'unit_01' => ['lessons' => [['id' => 'lesson_01', 'title' => 'درس آخر', 'file' => 'g10/math/u1/l1.md']]],
]]]],
]],
];
$row = [
'grade_key' => 'grade_10', 'subject_key' => 'math_10', 'semester_key' => 'semester_1',
'unit_key' => 'unit_01', 'lesson_key' => 'lesson_01', 'source_manifest_path' => 'g10/math/u1/l1.md',
'uuid' => '0e506aae-4699-49e4-a91d-aa3146e4addb', 'title' => 'العنوان المراجع',
'curriculum_version' => '2026-a', 'has_video' => true,
'primary_asset_id' => '47d85a37-bdaf-4f54-a8b8-216b619b5d04',
];
function expectTree(bool $condition, string $message): void
{
if (!$condition) throw new RuntimeException($message);
}
expectTree(PublishedCurriculumTreeFilter::filter($manifest, []) === [], 'No publications must yield an empty tree');
$visible = PublishedCurriculumTreeFilter::filter($manifest, [$row]);
expectTree(count($visible) === 1 && isset($visible['grade_10']), 'Unpublished grade leaked');
$subject = $visible['grade_10']['subjects']['math_10'];
$lessons = $subject['semesters']['semester_1']['units']['unit_01']['lessons'];
expectTree(count($lessons) === 1, 'Unpublished lesson leaked');
expectTree($lessons[0]['title'] === 'العنوان المراجع' && $lessons[0]['curriculum_lesson_id'] === $row['uuid'], 'Published identity/title not authoritative');
expectTree($lessons[0]['curriculum_version'] === '2026-a', 'Curriculum version missing');
expectTree($lessons[0]['primary_lesson_asset_id'] === $row['primary_asset_id'], 'Published markdown asset not linked');
expectTree($lessons[0]['has_video'] === true, 'Published video availability lost');
expectTree(!isset($lessons[0]['file']) && !isset($lessons[0]['outcomes']), 'Unreviewed manifest fields leaked');
expectTree($subject['resources']['textbooks']['items'] === [], 'Unpublished resource leaked');
expectTree(PublishedCurriculumTreeFilter::filter($manifest, [array_merge($row, ['source_manifest_path' => 'wrong.md'])]) === [], 'Wrong source path matched');
expectTree(PublishedCurriculumTreeFilter::filter($manifest, [array_merge($row, ['subject_key' => 'physics_10'])]) === [], 'Wrong subject matched');
echo "PASS 11 published curriculum filter checks\n";
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
// Pure local checks. No database, network, files written, or paid services.
require_once dirname(__DIR__) . '/app/Services/RemediationAttemptGrader.php';
use App\Services\RemediationAttemptGrader;
$ids = [11, 12, 13];
$key = [
['question_id' => 11, 'option_ids' => [101, 102], 'correct_option_id' => 102],
['question_id' => 12, 'option_ids' => [201, 202], 'correct_option_id' => 201],
['question_id' => 13, 'option_ids' => [301, 302], 'correct_option_id' => 302],
];
$checks = 0;
function expectGrade(array $ids, array $key, array $answers, ?int $expected): void
{
global $checks;
try {
$actual = RemediationAttemptGrader::grade($ids, $key, $answers);
if ($expected === null || $actual !== $expected) throw new RuntimeException('Unexpected grade');
} catch (InvalidArgumentException $e) {
if ($expected !== null) throw $e;
}
$checks++;
}
expectGrade($ids, $key, [102, 201, 302], 3);
expectGrade($ids, $key, [102, 202, 302], 2);
expectGrade($ids, $key, [101, 202, 301], 0);
expectGrade($ids, $key, [102, 201], null);
expectGrade($ids, $key, [102, 201, 999], null);
expectGrade($ids, $key, [102, 102, 302], null);
expectGrade($ids, $key, ['102', 201, 302], null);
expectGrade($ids, array_reverse($key), [102, 201, 302], null);
expectGrade([11, 11, 13], $key, [102, 201, 302], null);
echo "PASS {$checks} remediation grading cases\n";
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
// SQLite memory fixture: no production DB, no network, no money or student writes.
// MySQL/HTTP concurrency and real-school roster verification remain pending.
require_once dirname(__DIR__) . '/app/Core/Database.php';
require_once dirname(__DIR__) . '/app/Services/StudentAccessControlService.php';
use App\Core\Database;
use App\Services\StudentAccessControlService;
$pdo = class_exists('Pdo\\Sqlite') ? new \Pdo\Sqlite('sqlite::memory:') : new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
if ($pdo instanceof \Pdo\Sqlite) $pdo->createFunction('NOW', static fn() => gmdate('Y-m-d H:i:s'), 0);
else $pdo->sqliteCreateFunction('NOW', static fn() => gmdate('Y-m-d H:i:s'), 0);
(new ReflectionProperty(Database::class, 'instance'))->setValue(null, $pdo);
$pdo->exec('CREATE TABLE directorates (id INTEGER PRIMARY KEY, type TEXT)');
$pdo->exec('CREATE TABLE schools (id INTEGER PRIMARY KEY, name TEXT, type TEXT, directorate_id INTEGER)');
$pdo->exec('CREATE TABLE students (id INTEGER PRIMARY KEY, national_id TEXT, grade_level TEXT, school_id INTEGER, is_school_sponsored INTEGER)');
$pdo->exec('CREATE TABLE teachers (id INTEGER PRIMARY KEY, is_school_exclusive INTEGER, is_marketplace_public INTEGER)');
$pdo->exec('CREATE TABLE courses (id INTEGER PRIMARY KEY, teacher_id INTEGER, school_id INTEGER, is_school_exclusive INTEGER, is_published INTEGER, price_jod NUMERIC, grade_level TEXT)');
$pdo->exec('CREATE TABLE lessons (id INTEGER PRIMARY KEY, course_id INTEGER, is_free_preview INTEGER)');
$pdo->exec('CREATE TABLE school_rosters (school_id INTEGER, claimed_student_id INTEGER, is_claimed INTEGER, grade_level TEXT)');
$pdo->exec('CREATE TABLE course_access_passes (id INTEGER PRIMARY KEY, student_id INTEGER, course_id INTEGER, pass_type TEXT, expires_at TEXT, is_active INTEGER)');
$pdo->exec("INSERT INTO schools VALUES (1,'School A','private',NULL),(2,'School B','private',NULL)");
$pdo->exec("INSERT INTO students VALUES (1,'a','grade_10',NULL,0),(2,'b','grade_10',1,1),(3,'c','grade_10',2,1),(4,'d','grade_9',NULL,0),(5,'e','grade_10',1,1)");
$pdo->exec('INSERT INTO teachers VALUES (1,0,1),(2,1,0)');
$pdo->exec("INSERT INTO courses VALUES (10,1,NULL,0,1,10,'grade_10'),(11,2,1,1,1,30,'grade_10'),(12,2,2,1,1,30,'grade_10'),(13,1,NULL,0,1,0,'grade_10'),(14,1,NULL,0,0,0,'grade_10')");
$pdo->exec('INSERT INTO lessons VALUES (100,10,0),(110,11,0),(120,12,0),(130,13,0),(140,14,0)');
$pdo->exec("INSERT INTO school_rosters VALUES (1,2,1,'grade_10'),(2,3,1,'grade_10')");
$pdo->exec("INSERT INTO course_access_passes VALUES (1,1,10,'full_marketplace',NULL,1),(2,2,10,'school_included',NULL,1)");
$checks = 0;
function expectAccess(int $studentId, int $courseId, int $lessonId, bool $allowed, string $reason, bool $legacySideEffects = false): void
{
global $checks;
$result = StudentAccessControlService::validateLessonAccess($studentId, null, 'grade_10', $courseId, $lessonId, $legacySideEffects);
if ((bool)$result['allowed'] !== $allowed || (string)$result['reason'] !== $reason) {
throw new RuntimeException("Unexpected access for student {$studentId}, course {$courseId}: " . json_encode($result));
}
$checks++;
}
expectAccess(1, 10, 100, true, 'paid_pass_active');
expectAccess(1, 11, 110, false, 'school_scope_mismatch');
expectAccess(2, 11, 110, true, 'school_course_included');
expectAccess(2, 12, 120, false, 'school_scope_mismatch');
expectAccess(2, 10, 100, false, 'payment_required');
expectAccess(2, 13, 130, true, 'free_course');
expectAccess(5, 11, 110, false, 'school_roster_unverified');
expectAccess(4, 13, 130, false, 'grade_mismatch', true);
expectAccess(1, 14, 140, false, 'course_unavailable');
expectAccess(1, 10, 110, false, 'lesson_course_mismatch');
expectAccess(99, 13, 130, false, 'unauthenticated_or_not_found');
$grade = $pdo->query('SELECT grade_level FROM students WHERE id=4')->fetchColumn();
$passes = (int)$pdo->query('SELECT COUNT(*) FROM course_access_passes')->fetchColumn();
if ($grade !== 'grade_9' || $passes !== 2) throw new RuntimeException('Access reads changed grade or issued passes');
$checks++;
echo "PASS {$checks} local student-access scope checks (not an HTTP/MySQL test)\n";
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
// Isolated SQLite memory fixture. No server, production database, or money movement.
// FOR UPDATE concurrency semantics remain a separate MySQL staging test.
require_once dirname(__DIR__) . '/app/Core/Database.php';
require_once dirname(__DIR__) . '/app/Services/TeacherLedgerService.php';
use App\Core\Database;
use App\Services\TeacherLedgerService;
$pdo = class_exists('Pdo\\Sqlite') ? new \Pdo\Sqlite('sqlite::memory:') : new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
if ($pdo instanceof \Pdo\Sqlite) {
$pdo->createFunction('NOW', static fn() => gmdate('Y-m-d H:i:s'), 0);
} else {
$pdo->sqliteCreateFunction('NOW', static fn() => gmdate('Y-m-d H:i:s'), 0);
}
$property = new ReflectionProperty(Database::class, 'instance');
$property->setValue(null, $pdo);
$pdo->exec('CREATE TABLE ledger_accounts (id INTEGER PRIMARY KEY, code TEXT, owner_type TEXT, owner_id INTEGER, currency TEXT)');
$pdo->exec('CREATE TABLE ledger_entries (id INTEGER PRIMARY KEY, debit_account_id INTEGER, credit_account_id INTEGER, amount_fils INTEGER)');
$pdo->exec('CREATE TABLE teacher_withdrawal_holds (id INTEGER PRIMARY KEY, uuid TEXT, teacher_id INTEGER, amount_fils INTEGER, status TEXT, idempotency_key TEXT, released_at TEXT, release_reason TEXT)');
function checkLedger(bool $condition, string $message): void
{
if (!$condition) throw new RuntimeException($message);
}
$empty = TeacherLedgerService::balance(42);
checkLedger($empty['account_id'] === null && $empty['available_fils'] === 0, 'Missing account must have zero available');
checkLedger((int)$pdo->query('SELECT COUNT(*) FROM ledger_accounts')->fetchColumn() === 0, 'Balance read created an account');
$pdo->exec("INSERT INTO ledger_accounts VALUES (1, 'teacher_available', 'teacher', 42, 'JOD')");
$pdo->exec('INSERT INTO ledger_entries VALUES (1, 2, 1, 2000), (2, 1, 2, 500)');
$pdo->exec("INSERT INTO teacher_withdrawal_holds VALUES (1, '5de1e10c-a2ee-47c4-a9ba-5db6e894a94f', 42, 400, 'held', 'fixture-key', NULL, NULL)");
$balance = TeacherLedgerService::balance(42);
checkLedger($balance['gross_fils'] === 1500 && $balance['held_fils'] === 400 && $balance['available_fils'] === 1100, 'Signed net balance or hold subtraction wrong');
$wrongTeacher = TeacherLedgerService::releaseHold(43, '5de1e10c-a2ee-47c4-a9ba-5db6e894a94f', 'سبب مراجعة');
checkLedger($wrongTeacher['status'] === 'hold_not_releasable', 'Cross-teacher release was allowed');
$released = TeacherLedgerService::releaseHold(42, '5de1e10c-a2ee-47c4-a9ba-5db6e894a94f', 'فشل نهائي موثق');
checkLedger($released['status'] === 'released', 'First release failed');
$again = TeacherLedgerService::releaseHold(42, '5de1e10c-a2ee-47c4-a9ba-5db6e894a94f', 'إعادة');
checkLedger($again['status'] === 'hold_not_releasable', 'Second release changed state');
$row = $pdo->query('SELECT status, release_reason FROM teacher_withdrawal_holds WHERE id=1')->fetch(PDO::FETCH_ASSOC);
checkLedger($row['status'] === 'released' && $row['release_reason'] === 'فشل نهائي موثق', 'Release reason not persisted');
checkLedger(TeacherLedgerService::balance(42)['available_fils'] === 1500, 'Released hold still reduced availability');
$invalid = TeacherLedgerService::placeWithdrawalHold(42, 0, 'valid-key-1234567');
checkLedger($invalid['http_status'] === 400, 'Zero hold should be rejected before database access');
echo "PASS 9 local ledger checks (not a MySQL concurrency test)\n";
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
// Pure local validation: no database, network, files written, or AI calls.
require_once dirname(__DIR__) . '/app/Services/VideoCoverageValidator.php';
use App\Services\VideoCoverageValidator;
$sha = str_repeat('a', 64);
$valid = [
'video_sha256' => $sha,
'markdown_sha256' => str_repeat('b', 64),
'truncated' => false,
'gaps' => [],
'segments' => [
['start_seconds'=>0,'end_seconds'=>30,'transcript_ref'=>'t1','visual_ref'=>'v1'],
['start_seconds'=>30,'end_seconds'=>60,'transcript_ref'=>'t2','visual_ref'=>'v2'],
],
];
$cases = [
[$valid, true],
[array_replace($valid, ['truncated'=>true]), false],
[array_replace($valid, ['gaps'=>[[30,31]]]), false],
[array_replace($valid, ['video_sha256'=>str_repeat('c',64)]), false],
[array_replace($valid, ['segments'=>[['start_seconds'=>1,'end_seconds'=>60,'transcript_ref'=>'t','visual_ref'=>'v']]]), false],
[array_replace($valid, ['segments'=>[['start_seconds'=>0,'end_seconds'=>59,'transcript_ref'=>'t','visual_ref'=>'v']]]), false],
[array_replace($valid, ['segments'=>[['start_seconds'=>0,'end_seconds'=>30,'transcript_ref'=>'t','visual_ref'=>'v'],['start_seconds'=>31,'end_seconds'=>60,'transcript_ref'=>'t','visual_ref'=>'v']]]), false],
[array_replace($valid, ['segments'=>[['start_seconds'=>0,'end_seconds'=>60,'transcript_ref'=>'','visual_ref'=>'v']]]), false],
];
foreach ($cases as $index => [$coverage, $expected]) {
if (VideoCoverageValidator::valid($coverage, 60, $sha) !== $expected) {
throw new RuntimeException("Coverage case {$index} failed");
}
}
echo 'PASS ' . count($cases) . " video coverage cases\n";
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
// Pure local validation only; no database, network, writes, or AI calls.
require_once dirname(__DIR__) . '/app/Services/VideoReviewReportValidator.php';
use App\Services\VideoReviewReportValidator;
$good = [
'missing_inputs' => [], 'issues' => [],
'coverage' => [
'truncated' => false, 'unprocessed_intervals' => [],
'section_results' => [['status'=>'covered','evidence_refs'=>['segment-1']]],
'objective_results' => [['status'=>'covered','evidence_refs'=>['segment-1']]],
],
];
$cases = [
[$good, true],
[array_replace($good, ['missing_inputs'=>['transcript']]), false],
[array_replace_recursive($good, ['coverage'=>['truncated'=>true]]), false],
[array_replace_recursive($good, ['coverage'=>['unprocessed_intervals'=>[[0,5]]]]), false],
[array_replace_recursive($good, ['coverage'=>['section_results'=>[['status'=>'missing']]]]), false],
[array_replace($good, ['issues'=>[['severity'=>'major','resolution_status'=>'open']]]), false],
[array_replace($good, ['issues'=>[['severity'=>'critical','resolution_status'=>'resolved']]]), false],
[array_replace($good, ['issues'=>[['severity'=>'critical','resolution_status'=>'resolved','resolution_evidence'=>'reviewer-note-1']]]), true],
];
foreach ($cases as $index => [$report, $expected]) {
if (VideoReviewReportValidator::approvable($report) !== $expected) {
throw new RuntimeException("Report case {$index} failed");
}
}
echo 'PASS ' . count($cases) . " video review report cases\n";