canTransition('new', 'assigned')) throw new RuntimeException('new should transition to assigned.'); if ($transitions->canTransition('new', 'completed')) throw new RuntimeException('new should not skip to completed.'); if (!$transitions->canTransition('in_progress', 'in_progress')) throw new RuntimeException('Same status should be allowed.'); $validEntry = (new TimeEntryValidator())->validate(['work_date' => '2026-09-01', 'start_time' => '09:00', 'end_time' => '11:30']); if (!$validEntry['valid'] || $validEntry['hours'] !== 2.5 || $validEntry['errors'] !== []) throw new RuntimeException('Expected valid time entry.'); $invalidEntry = (new TimeEntryValidator())->validate(['work_date' => 'not-a-date', 'start_time' => '11:00', 'end_time' => '10:00']); if ($invalidEntry['valid'] || count($invalidEntry['errors']) !== 2) throw new RuntimeException('Expected invalid time entry errors.'); $nonNumericEntry = (new TimeEntryValidator())->validate(['work_date' => '2026-09-01', 'hours' => 'not-a-number']); if ($nonNumericEntry['valid'] || !isset($nonNumericEntry['errors']['hours'])) throw new RuntimeException('Expected non-numeric hours to be rejected.'); $aggregator = new TimeAggregator(); if ($aggregator->total([['hours' => 2.25], ['hours' => 1.5], ['hours' => -4]]) !== 3.75) throw new RuntimeException('Expected positive time aggregation.'); if ($aggregator->slaTotal([['hours' => 2, 'counts_toward_sla' => true], ['hours' => 3, 'counts_toward_sla' => false]]) !== 2.0) throw new RuntimeException('Expected SLA-filtered aggregation.'); printf("Jobcard domain tests: 12 passed\n");