diff --git a/webapp/src/Command/AbstractCompareCommand.php b/webapp/src/Command/AbstractCompareCommand.php new file mode 100644 index 0000000000..99c0d2cfc4 --- /dev/null +++ b/webapp/src/Command/AbstractCompareCommand.php @@ -0,0 +1,96 @@ + $compareService + */ + public function __construct( + protected readonly SerializerInterface $serializer, + protected AbstractCompareService $compareService + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addArgument('file1', InputArgument::REQUIRED, 'First file to compare') + ->addArgument('file2', InputArgument::REQUIRED, 'Second file to compare'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $style = new SymfonyStyle($input, $output); + $messages = $this->compareService->compareFiles($input->getArgument('file1'), $input->getArgument('file2')); + + return $this->displayMessages($style, $messages) ?? Command::SUCCESS; + } + + /** + * @param Message[] $messages + */ + protected function displayMessages(SymfonyStyle $style, array $messages): ?int + { + if (empty($messages)) { + $style->success('Files match fully'); + return null; + } + + $headers = ['Level', 'Message', 'Source', 'Target']; + $rows = []; + $counts = []; + foreach ($messages as $message) { + if (!isset($counts[$message->type->value])) { + $counts[$message->type->value] = 0; + } + $counts[$message->type->value]++; + $rows[] = [ + $this->formatMessage($message->type, $message->type->value), + $this->formatMessage($message->type, $message->message), + $this->formatMessage($message->type, $message->source ?? ''), + $this->formatMessage($message->type, $message->target ?? ''), + ]; + } + $style->table($headers, $rows); + + $style->newLine(); + foreach ($counts as $type => $count) { + $style->writeln($this->formatMessage(MessageType::from($type), sprintf('Found %d %s(s)', $count, $type))); + } + + if (isset($counts['error'])) { + $style->error('Files have potential critical differences'); + return Command::FAILURE; + } + + $style->success('Files have differences but probably non critical'); + + return null; + } + + protected function formatMessage(MessageType $level, string $message): string + { + $colors = [ + MessageType::ERROR->value => 'red', + MessageType::WARNING->value => 'yellow', + MessageType::INFO->value => 'green', + ]; + return sprintf('%s', $colors[$level->value], $message); + } +} diff --git a/webapp/src/Command/CompareAwardsCommand.php b/webapp/src/Command/CompareAwardsCommand.php new file mode 100644 index 0000000000..757c73f29c --- /dev/null +++ b/webapp/src/Command/CompareAwardsCommand.php @@ -0,0 +1,25 @@ + + */ +#[AsCommand( + name: 'compare:awards', + description: 'Compare awards between two files' +)] +class CompareAwardsCommand extends AbstractCompareCommand +{ + public function __construct( + SerializerInterface $serializer, + AwardCompareService $compareService + ) { + parent::__construct($serializer, $compareService); + } +} diff --git a/webapp/src/Command/CompareResultsCommand.php b/webapp/src/Command/CompareResultsCommand.php new file mode 100644 index 0000000000..f12d0ced8e --- /dev/null +++ b/webapp/src/Command/CompareResultsCommand.php @@ -0,0 +1,25 @@ + + */ +#[AsCommand( + name: 'compare:results', + description: 'Compare results between two files' +)] +class CompareResultsCommand extends AbstractCompareCommand +{ + public function __construct( + SerializerInterface $serializer, + ResultsCompareService $compareService + ) { + parent::__construct($serializer, $compareService); + } +} diff --git a/webapp/src/Command/CompareScoreboardCommand.php b/webapp/src/Command/CompareScoreboardCommand.php new file mode 100644 index 0000000000..c4eb4b8c03 --- /dev/null +++ b/webapp/src/Command/CompareScoreboardCommand.php @@ -0,0 +1,25 @@ + + */ +#[AsCommand( + name: 'compare:scoreboard', + description: 'Compare scoreboard between two files' +)] +class CompareScoreboardCommand extends AbstractCompareCommand +{ + public function __construct( + SerializerInterface $serializer, + ScoreboardCompareService $compareService + ) { + parent::__construct($serializer, $compareService); + } +} diff --git a/webapp/src/DataTransferObject/Award.php b/webapp/src/DataTransferObject/Award.php index eb5f2ade8d..fa3fc8faeb 100644 --- a/webapp/src/DataTransferObject/Award.php +++ b/webapp/src/DataTransferObject/Award.php @@ -11,7 +11,7 @@ class Award */ public function __construct( public readonly string $id, - public readonly string $citation, + public readonly ?string $citation, #[Serializer\Type('array')] public readonly array $teamIds, ) {} diff --git a/webapp/src/DataTransferObject/ContestState.php b/webapp/src/DataTransferObject/ContestState.php index e10d0ef6ed..9296df9b5e 100644 --- a/webapp/src/DataTransferObject/ContestState.php +++ b/webapp/src/DataTransferObject/ContestState.php @@ -5,11 +5,11 @@ class ContestState { public function __construct( - public readonly ?string $started, - public readonly ?string $ended, - public readonly ?string $frozen, - public readonly ?string $thawed, - public readonly ?string $finalized, - public readonly ?string $endOfUpdates, + public readonly ?string $started = null, + public readonly ?string $ended = null, + public readonly ?string $frozen = null, + public readonly ?string $thawed = null, + public readonly ?string $finalized = null, + public readonly ?string $endOfUpdates = null, ) {} } diff --git a/webapp/src/DataTransferObject/Scoreboard/Problem.php b/webapp/src/DataTransferObject/Scoreboard/Problem.php index 4f0d363825..b83613aa06 100644 --- a/webapp/src/DataTransferObject/Scoreboard/Problem.php +++ b/webapp/src/DataTransferObject/Scoreboard/Problem.php @@ -9,7 +9,7 @@ class Problem { public function __construct( #[Serializer\Groups([ARC::GROUP_NONSTRICT])] - public readonly string $label, + public readonly ?string $label, public readonly string $problemId, public readonly int $numJudged, public readonly int $numPending, diff --git a/webapp/src/Service/Compare/AbstractCompareService.php b/webapp/src/Service/Compare/AbstractCompareService.php new file mode 100644 index 0000000000..783bacba21 --- /dev/null +++ b/webapp/src/Service/Compare/AbstractCompareService.php @@ -0,0 +1,84 @@ +addMessage(MessageType::ERROR, sprintf('File "%s" does not exist', $file1)); + $success = false; + } + if (!file_exists($file2)) { + $this->addMessage(MessageType::ERROR, sprintf('File "%s" does not exist', $file2)); + $success = false; + } + if (!$success) { + return $this->messages; + } + + try { + $object1 = $this->parseFile($file1); + } catch (ExceptionInterface $e) { + $this->addMessage(MessageType::ERROR, sprintf('Error deserializing file "%s": %s', $file1, $e->getMessage())); + } + try { + $object2 = $this->parseFile($file2); + } catch (ExceptionInterface $e) { + $this->addMessage(MessageType::ERROR, sprintf('Error deserializing file "%s": %s', $file2, $e->getMessage())); + } + + if (!isset($object1) || !isset($object2)) { + return $this->messages; + } + + $this->compare($object1, $object2); + + return $this->messages; + } + + /** + * @return T|null + * @throws ExceptionInterface + */ + abstract protected function parseFile(string $file); + + /** + * @param T $object1 + * @param T $object2 + */ + abstract public function compare($object1, $object2): void; + + protected function addMessage( + MessageType $type, + string $message, + ?string $source = null, + ?string $target = null, + ): void { + $this->messages[] = new Message($type, $message, $source, $target); + } + + /** + * @return Message[] + */ + public function getMessages(): array + { + return $this->messages; + } +} diff --git a/webapp/src/Service/Compare/AwardCompareService.php b/webapp/src/Service/Compare/AwardCompareService.php new file mode 100644 index 0000000000..961de608c7 --- /dev/null +++ b/webapp/src/Service/Compare/AwardCompareService.php @@ -0,0 +1,61 @@ + + */ +class AwardCompareService extends AbstractCompareService +{ + protected function parseFile(string $file) + { + return $this->serializer->deserialize(file_get_contents($file), Award::class . '[]', 'json'); + } + + public function compare($object1, $object2): void + { + $awards1Indexed = []; + foreach ($object1 as $award) { + $awards1Indexed[$award->id] = $award; + } + + $awards2Indexed = []; + foreach ($object2 as $award) { + $awards2Indexed[$award->id] = $award; + } + + foreach ($awards1Indexed as $awardId => $award) { + if (!isset($awards2Indexed[$awardId])) { + if (!$award->teamIds) { + $this->addMessage(MessageType::INFO, sprintf('Award "%s" not found in second file, but has no team ID\'s in first file', $awardId)); + } else { + $this->addMessage(MessageType::ERROR, sprintf('Award "%s" not found in second file', $awardId)); + } + } else { + $award2 = $awards2Indexed[$awardId]; + if ($award->citation !== $award2->citation) { + $this->addMessage(MessageType::WARNING, sprintf('Award "%s" has different citation', $awardId), $award->citation, $award2->citation); + } + $award1TeamIds = $award->teamIds; + sort($award1TeamIds); + $award2TeamIds = $award2->teamIds; + sort($award2TeamIds); + if ($award1TeamIds !== $award2TeamIds) { + $this->addMessage(MessageType::ERROR, sprintf('Award "%s" has different team ID\'s', $awardId), implode(', ', $award->teamIds), implode(', ', $award2->teamIds)); + } + } + } + + foreach ($awards2Indexed as $awardId => $award) { + if (!isset($awards1Indexed[$awardId])) { + if (!$award->teamIds) { + $this->addMessage(MessageType::INFO, sprintf('Award "%s" not found in first file, but has no team ID\'s in second file', $awardId)); + } else { + $this->addMessage(MessageType::ERROR, sprintf('Award "%s" not found in first file', $awardId)); + } + } + } + } +} diff --git a/webapp/src/Service/Compare/Message.php b/webapp/src/Service/Compare/Message.php new file mode 100644 index 0000000000..cd198a3436 --- /dev/null +++ b/webapp/src/Service/Compare/Message.php @@ -0,0 +1,13 @@ + + */ +class ResultsCompareService extends AbstractCompareService +{ + protected function parseFile(string $file) + { + $resultsContents = file_get_contents($file); + if (!str_starts_with($resultsContents, "results\t1")) { + $this->addMessage(MessageType::ERROR, sprintf("File \"%s\" does not start with \"results\t1\"", $file)); + return null; + } + + $resultsContents = substr($resultsContents, strpos($resultsContents, "\n") + 1); + + // Prefix file with a fake header, so we can deserialize them + $resultsContents = "team_id\trank\taward\tnum_solved\ttotal_time\ttime_of_last_submission\tgroup_winner\n" . $resultsContents; + + $results = $this->serializer->deserialize($resultsContents, ResultRow::class . '[]', 'csv', [ + CsvEncoder::DELIMITER_KEY => "\t", + ]); + + // Sort results: first by num_solved, then by total_time + usort($results, fn( + ResultRow $a, + ResultRow $b + ) => $a->numSolved === $b->numSolved ? $a->totalTime <=> $b->totalTime : $b->numSolved <=> $a->numSolved); + + return $results; + } + + public function compare($object1, $object2): void + { + /** @var array $results1Indexed */ + $results1Indexed = []; + foreach ($object1 as $result) { + $results1Indexed[$result->teamId] = $result; + } + + /** @var array $results2Indexed */ + $results2Indexed = []; + foreach ($object2 as $result) { + $results2Indexed[$result->teamId] = $result; + } + + foreach ($object1 as $result) { + if (!isset($results2Indexed[$result->teamId])) { + $this->addMessage(MessageType::ERROR, sprintf('Team "%s" not found in second file', $result->teamId)); + } else { + $result2 = $results2Indexed[$result->teamId]; + if ($result->rank !== $result2->rank) { + $this->addMessage(MessageType::ERROR, sprintf('Team "%s" has different rank', $result->teamId), (string)$result->rank, (string)$result2->rank); + } + if ($result->award !== $result2->award) { + $this->addMessage(MessageType::ERROR, sprintf('Team "%s" has different award', $result->teamId), $result->award, $result2->award); + } + if ($result->numSolved !== $result2->numSolved) { + $this->addMessage(MessageType::ERROR, sprintf('Team "%s" has different num solved', $result->teamId), (string)$result->numSolved, (string)$result2->numSolved); + } + if ($result->totalTime !== $result2->totalTime) { + $this->addMessage(MessageType::ERROR, sprintf('Team "%s" has different total time', $result->teamId), (string)$result->totalTime, (string)$result2->totalTime); + } + if ($result->timeOfLastSubmission !== $result2->timeOfLastSubmission) { + $this->addMessage(MessageType::ERROR, sprintf('Team "%s" has different last time', $result->teamId), (string)$result->timeOfLastSubmission, (string)$result2->timeOfLastSubmission); + } + if ($result->groupWinner !== $result2->groupWinner) { + $this->addMessage(MessageType::WARNING, sprintf('Team "%s" has different group winner', $result->teamId), (string)$result->groupWinner, (string)$result2->groupWinner); + } + } + } + + foreach ($object2 as $result) { + if (!isset($results1Indexed[$result->teamId])) { + $this->addMessage(MessageType::ERROR, sprintf('Team "%s" not found in first file', $result->teamId)); + } + } + } +} diff --git a/webapp/src/Service/Compare/ScoreboardCompareService.php b/webapp/src/Service/Compare/ScoreboardCompareService.php new file mode 100644 index 0000000000..1f05941ae9 --- /dev/null +++ b/webapp/src/Service/Compare/ScoreboardCompareService.php @@ -0,0 +1,134 @@ + + */ +class ScoreboardCompareService extends AbstractCompareService +{ + protected function parseFile(string $file) + { + return $this->serializer->deserialize(file_get_contents($file), Scoreboard::class, 'json', ['disable_type_enforcement' => true]); + } + + public function compare($object1, $object2): void + { + if ($object1->eventId !== $object2->eventId) { + $this->addMessage(MessageType::INFO, 'Event ID does not match', $object1->eventId, $object2->eventId); + } + + if ($object1->time !== $object2->time) { + $this->addMessage(MessageType::INFO, 'Time does not match', $object1->time, $object2->time); + } + + if ($object1->contestTime !== $object2->contestTime) { + $this->addMessage(MessageType::INFO, 'Contest time does not match', $object1->contestTime, $object2->contestTime); + } + + if (($object1->state->started ?? '') !== ($object2->state->started ?? '')) { + $this->addMessage(MessageType::WARNING, 'State started does not match', $object1->state->started, $object2->state->started); + } + + if (($object1->state->ended ?? '') !== ($object2->state->ended ?? '')) { + $this->addMessage(MessageType::WARNING, 'State ended does not match', $object1->state->ended, $object2->state->ended); + } + + if (($object1->state->frozen ?? '') !== ($object2->state->frozen ?? '')) { + $this->addMessage(MessageType::WARNING, 'State frozen does not match', $object1->state->frozen, $object2->state->frozen); + } + + if (($object1->state->thawed ?? '') !== ($object2->state->thawed ?? '')) { + $this->addMessage(MessageType::WARNING, 'State thawed does not match', $object1->state->thawed, $object2->state->thawed); + } + + if (($object1->state->finalized ?? '') !== ($object2->state->finalized ?? '')) { + $this->addMessage(MessageType::WARNING, 'State finalized does not match', $object1->state->finalized, $object2->state->finalized); + } + + if (($object1->state->endOfUpdates ?? '') !== ($object2->state->endOfUpdates ?? '')) { + $this->addMessage(MessageType::WARNING, 'State end of updates does not match', $object1->state->endOfUpdates, $object2->state->endOfUpdates); + } + + if (count($object1->rows) !== count($object2->rows)) { + $this->addMessage(MessageType::ERROR, 'Number of rows does not match', (string)count($object1->rows), (string)count($object2->rows)); + } + + foreach ($object1->rows as $index => $row) { + if ($row->teamId !== $object2->rows[$index]->teamId) { + $this->addMessage(MessageType::ERROR, sprintf('Row %d: team ID does not match', $index), $row->teamId, $object2->rows[$index]->teamId); + } + + if ($row->rank !== $object2->rows[$index]->rank) { + $this->addMessage(MessageType::ERROR, sprintf('Row %d: rank does not match', $index), (string)$row->rank, (string)$object2->rows[$index]->rank); + } + + if ($row->score->numSolved !== $object2->rows[$index]->score->numSolved) { + $this->addMessage(MessageType::ERROR, sprintf('Row %d: num solved does not match', $index), (string)$row->score->numSolved, (string)$object2->rows[$index]->score->numSolved); + } + + if ($row->score->totalTime !== $object2->rows[$index]->score->totalTime) { + $this->addMessage(MessageType::ERROR, sprintf('Row %d: total time does not match', $index), (string)$row->score->totalTime, (string)$object2->rows[$index]->score->totalTime); + } + + foreach ($row->problems as $problem) { + /** @var Problem|null $problemForSecond */ + $problemForSecond = null; + + foreach ($object2->rows[$index]->problems as $problem2) { + // PC^2 uses different problem ID's. For now also match on `Id = {problemId}-{digits}` + if ($problem->problemId === $problem2->problemId) { + $problemForSecond = $problem2; + break; + } elseif (preg_match('/^Id = ' . preg_quote($problem->problemId, '/') . '-+\d+$/', $problem2->problemId)) { + $problemForSecond = $problem2; + break; + } + } + + if ($problemForSecond === null && $problem->solved) { + $this->addMessage(MessageType::ERROR, sprintf('Row %d: Problem %s solved in first file, but not found in second file', $index, $problem->problemId)); + } elseif ($problemForSecond !== null && $problem->solved !== $problemForSecond->solved) { + $this->addMessage(MessageType::ERROR, sprintf('Row %d: Problem %s solved does not match', $index, $problem->problemId), (string)$problem->solved, (string)$problemForSecond->solved); + } + + if ($problemForSecond) { + if ($problem->numJudged !== $problemForSecond->numJudged) { + $this->addMessage(MessageType::ERROR, sprintf('Row %d: Problem %s num judged does not match', $index, $problem->problemId), (string)$problem->numJudged, (string)$problemForSecond->numJudged); + } + + if ($problem->numPending !== $problemForSecond->numPending) { + $this->addMessage(MessageType::ERROR, sprintf('Row %d: Problem %s num pending does not match', $index, $problem->problemId), (string)$problem->numPending, (string)$problemForSecond->numPending); + } + + if ($problem->time !== $problemForSecond->time) { + // This is an info message for now, since PC^2 doesn't expose time info + $this->addMessage(MessageType::INFO, sprintf('Row %d: Problem %s time does not match', $index, $problem->problemId), (string)$problem->time, (string)$problemForSecond->time); + } + } + } + + foreach ($object2->rows[$index]->problems as $problem2) { + $problemForFirst = null; + + foreach ($row->problems as $problem) { + // PC^2 uses different problem ID's. For now also match on `Id = {problemId}-{digits}` + if ($problem->problemId === $problem2->problemId) { + $problemForFirst = $problem; + break; + } elseif (preg_match('/^Id = ' . preg_quote($problem->problemId, '/') . '-+\d+$/', $problem2->problemId)) { + $problemForFirst = $problem; + break; + } + } + + if ($problemForFirst === null && $problem2->solved) { + $this->addMessage(MessageType::ERROR, sprintf('Row %d: Problem %s solved in second file, but not found in first file', $index, $problem2->problemId)); + } + } + } + } +} diff --git a/webapp/tests/Unit/Service/Compare/AwardCompareServiceTest.php b/webapp/tests/Unit/Service/Compare/AwardCompareServiceTest.php new file mode 100644 index 0000000000..55f7b03277 --- /dev/null +++ b/webapp/tests/Unit/Service/Compare/AwardCompareServiceTest.php @@ -0,0 +1,67 @@ +createMock(SerializerInterface::class); + $compareService = new AwardCompareService($serializer); + + $compareService->compare($awards1, $awards2); + $messages = $compareService->getMessages(); + + self::assertEquals($expectedMessages, $messages); + } + + public function provideCompare(): Generator + { + yield [[], [], []]; + yield [ + [new Award('award1', null, [])], + [], + [new Message(MessageType::INFO, 'Award "award1" not found in second file, but has no team ID\'s in first file', null, null)], + ]; + yield [ + [], + [new Award('award2', null, [])], + [new Message(MessageType::INFO, 'Award "award2" not found in first file, but has no team ID\'s in second file', null, null)], + ]; + yield [ + [new Award('award3', null, ["1", "2", "3"])], + [], + [new Message(MessageType::ERROR, 'Award "award3" not found in second file', null, null)], + ]; + yield [ + [], + [new Award('award4', null, ["1", "2", "3"])], + [new Message(MessageType::ERROR, 'Award "award4" not found in first file', null, null)], + ]; + yield [ + [new Award('award1', 'citation1', [])], + [new Award('award1', 'citation2', [])], + [new Message(MessageType::WARNING, 'Award "award1" has different citation', 'citation1', 'citation2')], + ]; + yield [ + [new Award('award1', 'citation1', ["1", "2"])], + [new Award('award1', 'citation1', ["2", "3"])], + [new Message(MessageType::ERROR, 'Award "award1" has different team ID\'s', '1, 2', '2, 3')], + ]; + } +} diff --git a/webapp/tests/Unit/Service/Compare/ResultsCompareServiceTest.php b/webapp/tests/Unit/Service/Compare/ResultsCompareServiceTest.php new file mode 100644 index 0000000000..4c5124ef01 --- /dev/null +++ b/webapp/tests/Unit/Service/Compare/ResultsCompareServiceTest.php @@ -0,0 +1,77 @@ +createMock(SerializerInterface::class); + $compareService = new ResultsCompareService($serializer); + + $compareService->compare($results1, $results2); + $messages = $compareService->getMessages(); + + self::assertEquals($expectedMessages, $messages); + } + + public function provideCompare(): Generator + { + yield [[], [], []]; + yield [ + [new ResultRow('team1', 1, '', 0, 0, 0)], + [], + [new Message(MessageType::ERROR, 'Team "team1" not found in second file', null, null)], + ]; + yield [ + [], + [new ResultRow('team2', 1, '', 0, 0, 0)], + [new Message(MessageType::ERROR, 'Team "team2" not found in first file', null, null)], + ]; + yield [ + [new ResultRow('team3', 1, '', 0, 0, 0)], + [new ResultRow('team3', 2, '', 0, 0, 0)], + [new Message(MessageType::ERROR, 'Team "team3" has different rank', '1', '2')], + ]; + yield [ + [new ResultRow('team4', 1, 'award1', 0, 0, 0)], + [new ResultRow('team4', 1, 'award2', 0, 0, 0)], + [new Message(MessageType::ERROR, 'Team "team4" has different award', 'award1', 'award2')], + ]; + yield [ + [new ResultRow('team5', 1, 'award3', 1, 0, 0)], + [new ResultRow('team5', 1, 'award3', 2, 0, 0)], + [new Message(MessageType::ERROR, 'Team "team5" has different num solved', '1', '2')], + ]; + yield [ + [new ResultRow('team6', 1, 'award4', 1, 100, 0)], + [new ResultRow('team6', 1, 'award4', 1, 200, 0)], + [new Message(MessageType::ERROR, 'Team "team6" has different total time', '100', '200')], + ]; + yield [ + [new ResultRow('team7', 1, 'award4', 1, 100, 10)], + [new ResultRow('team7', 1, 'award4', 1, 100, 20)], + [new Message(MessageType::ERROR, 'Team "team7" has different last time', '10', '20')], + ]; + yield [ + [new ResultRow('team8', 1, 'award4', 1, 100, 10, 'winner1')], + [new ResultRow('team8', 1, 'award4', 1, 100, 10, 'winner2')], + [new Message(MessageType::WARNING, 'Team "team8" has different group winner', 'winner1', 'winner2')], + ]; + } +} diff --git a/webapp/tests/Unit/Service/Compare/ScoreboardCompareServiceTest.php b/webapp/tests/Unit/Service/Compare/ScoreboardCompareServiceTest.php new file mode 100644 index 0000000000..84879451e8 --- /dev/null +++ b/webapp/tests/Unit/Service/Compare/ScoreboardCompareServiceTest.php @@ -0,0 +1,188 @@ +createMock(SerializerInterface::class); + $compareService = new ScoreboardCompareService($serializer); + + $compareService->compare($scoreboard1, $scoreboard2); + $messages = $compareService->getMessages(); + + self::assertEquals($expectedMessages, $messages); + } + + public function provideCompare(): Generator + { + yield [new Scoreboard(), new Scoreboard(), []]; + yield [ + new Scoreboard('123'), + new Scoreboard('456'), + [new Message(MessageType::INFO, 'Event ID does not match', '123', '456')], + ]; + yield [ + new Scoreboard('123', '456'), + new Scoreboard('123', '123'), + [new Message(MessageType::INFO, 'Time does not match', '456', '123')], + ]; + yield [ + new Scoreboard('123', '456', '111'), + new Scoreboard('123', '456', '222'), + [new Message(MessageType::INFO, 'Contest time does not match', '111', '222')], + ]; + yield [ + new Scoreboard('123', '456', '111', new ContestState(started: '123')), + new Scoreboard('123', '456', '111', new ContestState(started: '456')), + [new Message(MessageType::WARNING, 'State started does not match', '123', '456')], + ]; + yield [ + new Scoreboard('123', '456', '111', new ContestState(ended: '123')), + new Scoreboard('123', '456', '111', new ContestState(ended: '456')), + [new Message(MessageType::WARNING, 'State ended does not match', '123', '456')], + ]; + yield [ + new Scoreboard('123', '456', '111', new ContestState(frozen: '123')), + new Scoreboard('123', '456', '111', new ContestState(frozen: '456')), + [new Message(MessageType::WARNING, 'State frozen does not match', '123', '456')], + ]; + yield [ + new Scoreboard('123', '456', '111', new ContestState(thawed: '123')), + new Scoreboard('123', '456', '111', new ContestState(thawed: '456')), + [new Message(MessageType::WARNING, 'State thawed does not match', '123', '456')], + ]; + yield [ + new Scoreboard('123', '456', '111', new ContestState(finalized: '123')), + new Scoreboard('123', '456', '111', new ContestState(finalized: '456')), + [new Message(MessageType::WARNING, 'State finalized does not match', '123', '456')], + ]; + yield [ + new Scoreboard('123', '456', '111', new ContestState(endOfUpdates: '123')), + new Scoreboard('123', '456', '111', new ContestState(endOfUpdates: '456')), + [new Message(MessageType::WARNING, 'State end of updates does not match', '123', '456')], + ]; + yield [ + new Scoreboard(rows: []), + new Scoreboard(rows: [new Row(1, '123', new Score(0), [])]), + [new Message(MessageType::ERROR, 'Number of rows does not match', '0', '1')], + ]; + yield [ + new Scoreboard(rows: [new Row(1, '123', new Score(0), [])]), + new Scoreboard(rows: [new Row(1, '456', new Score(0), [])]), + [new Message(MessageType::ERROR, 'Row 0: team ID does not match', '123', '456')], + ]; + yield [ + new Scoreboard(rows: [new Row(1, '123', new Score(0), [])]), + new Scoreboard(rows: [new Row(2, '123', new Score(0), [])]), + [new Message(MessageType::ERROR, 'Row 0: rank does not match', '1', '2')], + ]; + yield [ + new Scoreboard(rows: [new Row(1, '123', new Score(1), [])]), + new Scoreboard(rows: [new Row(1, '123', new Score(2), [])]), + [new Message(MessageType::ERROR, 'Row 0: num solved does not match', '1', '2')], + ]; + yield [ + new Scoreboard(rows: [new Row(1, '123', new Score(1, 123), [])]), + new Scoreboard(rows: [new Row(1, '123', new Score(1, 456), [])]), + [new Message(MessageType::ERROR, 'Row 0: total time does not match', '123', '456')], + ]; + yield [ + new Scoreboard(rows: [new Row(1, '123', new Score(1), [ + new Problem('A', 'a', 1, 0, true), + ])]), + new Scoreboard(rows: [new Row(1, '123', new Score(1), [ + new Problem('A', 'a', 1, 0, true), + ])]), + [], + ]; + yield [ + new Scoreboard(rows: [new Row(1, '123', new Score(1), [ + new Problem('A', 'a', 1, 0, true), + ])]), + new Scoreboard(rows: [new Row(1, '123', new Score(1), [ + new Problem('A', 'a', 1, 0, false), + ])]), + [new Message(MessageType::ERROR, 'Row 0: Problem a solved does not match', '1', '')], + ]; + yield [ + new Scoreboard(rows: [new Row(1, '123', new Score(1), [ + new Problem('A', 'a', 1, 0, true), + ])]), + new Scoreboard(rows: [new Row(1, '123', new Score(1), [])]), + [new Message(MessageType::ERROR, 'Row 0: Problem a solved in first file, but not found in second file')], + ]; + yield [ + new Scoreboard(rows: [new Row(1, '123', new Score(1), [])]), + new Scoreboard(rows: [new Row(1, '123', new Score(1), [ + new Problem('A', 'a', 1, 0, true), + ])]), + [new Message(MessageType::ERROR, 'Row 0: Problem a solved in second file, but not found in first file')], + ]; + yield [ + new Scoreboard(rows: [new Row(1, '123', new Score(1), [ + new Problem('A', 'a', 1, 0, true), + ])]), + new Scoreboard(rows: [new Row(1, '123', new Score(1), [ + new Problem('A', 'a', 2, 0, true), + ])]), + [new Message(MessageType::ERROR, 'Row 0: Problem a num judged does not match', '1', '2')], + ]; + yield [ + new Scoreboard(rows: [new Row(1, '123', new Score(1), [ + new Problem('A', 'a', 1, 3, true), + ])]), + new Scoreboard(rows: [new Row(1, '123', new Score(1), [ + new Problem('A', 'a', 1, 4, true), + ])]), + [new Message(MessageType::ERROR, 'Row 0: Problem a num pending does not match', '3', '4')], + ]; + yield [ + new Scoreboard(rows: [new Row(1, '123', new Score(1), [ + new Problem('A', 'a', 1, 3, true, 123), + ])]), + new Scoreboard(rows: [new Row(1, '123', new Score(1), [ + new Problem('A', 'a', 1, 3, true, 456), + ])]), + [new Message(MessageType::INFO, 'Row 0: Problem a time does not match', '123', '456')], + ]; + // PC^2 uses different problem ID's. Also test on `Id = {problemId}-{digits}` + yield [ + new Scoreboard(rows: [new Row(1, '123', new Score(1), [ + new Problem('A', 'a', 1, 0, true), + ])]), + new Scoreboard(rows: [new Row(1, '123', new Score(1), [ + new Problem('A', 'Id = a-123', 1, 0, true), + ])]), + [], + ]; + yield [ + new Scoreboard(rows: [new Row(1, '123', new Score(1), [])]), + new Scoreboard(rows: [new Row(1, '123', new Score(1), [ + new Problem('A', 'Id = a-123', 1, 0, true), + ])]), + [new Message(MessageType::ERROR, 'Row 0: Problem Id = a-123 solved in second file, but not found in first file')], + ]; + } +}