Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Test case explanation #2345

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion judge/admin/problem.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ class ProblemAdmin(NoBatchDeleteMixin, VersionAdmin):
'fields': (
'code', 'name', 'is_public', 'is_manually_managed', 'date', 'authors', 'curators', 'testers',
'organizations', 'submission_source_visibility_mode', 'is_full_markup',
'description', 'license',
'description', 'include_test_cases', 'license',
),
}),
(_('Social Media'), {'classes': ('collapse',), 'fields': ('og_image', 'summary')}),
Expand Down
85 changes: 85 additions & 0 deletions judge/migrations/0147_infer_test_cases_from_zip.py

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions judge/migrations/0148_test_case_explanation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 3.2.25 on 2024-08-18 07:16

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('judge', '0147_infer_test_cases_from_zip'),
]

operations = [
migrations.AddField(
model_name='problemtestcase',
name='explanation_file',
field=models.CharField(blank=True, null=True, default='', max_length=100, verbose_name='explanation file name'),
),
]
13 changes: 12 additions & 1 deletion judge/models/problem.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
from django.db import models
from django.db.models import CASCADE, Exists, F, FilteredRelation, OuterRef, Q, SET_NULL
Expand Down Expand Up @@ -120,6 +120,9 @@ class Problem(models.Model):
name = models.CharField(max_length=100, verbose_name=_('problem name'), db_index=True,
help_text=_('The full name of the problem, as shown in the problem list.'),
validators=[disallowed_characters_validator])
include_test_cases = models.BooleanField(verbose_name=_('include test cases'),
help_text=_('If true, the inputs and otuputs of every test case will '
'be automatically added after the body.'), default=False)
description = models.TextField(verbose_name=_('problem body'), validators=[disallowed_characters_validator])
authors = models.ManyToManyField(Profile, verbose_name=_('creators'), blank=True, related_name='authored_problems',
help_text=_('These users will be able to edit the problem, '
Expand Down Expand Up @@ -454,6 +457,7 @@ def markdown_style(self):

def save(self, *args, **kwargs):
super(Problem, self).save(*args, **kwargs)

if self.code != self.__original_code:
try:
problem_data = self.data_files
Expand All @@ -462,6 +466,13 @@ def save(self, *args, **kwargs):
else:
problem_data._update_code(self.__original_code, self.code)

if self.include_test_cases:
try:
self.data_files.setup_test_cases_content()
self.data_files.save()
except ObjectDoesNotExist:
pass

save.alters_data = True

def is_solved_by(self, user):
Expand Down
227 changes: 225 additions & 2 deletions judge/models/problem_data.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import errno
import os
from zipfile import ZipFile

import yaml
from django.db import models
from django.utils.translation import gettext_lazy as _

from judge.utils.problem_data import ProblemDataStorage


__all__ = ['problem_data_storage', 'problem_directory_file', 'ProblemData', 'ProblemTestCase', 'CHECKERS']

problem_data_storage = ProblemDataStorage()
Expand Down Expand Up @@ -38,6 +41,8 @@ class ProblemData(models.Model):
upload_to=problem_directory_file)
generator = models.FileField(verbose_name=_('generator file'), storage=problem_data_storage, null=True, blank=True,
upload_to=problem_directory_file)
infer_from_zip = models.BooleanField(verbose_name=_('infer test cases from zip'), null=True, blank=True)
test_cases_content = models.TextField(verbose_name=_('test cases content'), blank=True)
output_prefix = models.IntegerField(verbose_name=_('output prefix length'), blank=True, null=True)
output_limit = models.IntegerField(verbose_name=_('output limit length'), blank=True, null=True)
feedback = models.TextField(verbose_name=_('init.yml generation feedback'), blank=True)
Expand All @@ -53,9 +58,22 @@ def __init__(self, *args, **kwargs):
super(ProblemData, self).__init__(*args, **kwargs)
self.__original_zipfile = self.zipfile

if not self.zipfile:
# Test cases not loaded through the site, but some data has been found within the problem folder
if self.has_yml():
self.feedback = 'Warning: problem data found within the file system, but none has been setup '\
'using this site. No actions are needed if the problem is working as intended; '\
"otherwise, you can <a id='perform_infer_test_cases' href='javascript:void(0);'>"\
'infer the testcases using the existing zip file (one entry per file within the '\
"zip)</a> or <a id='perform_rebuild_test_cases' href='javascript:void(0);'>"\
'rebuild the test cases using the existing yml file as a template (only works'\
'with simple problems)</a>.'

def save(self, *args, **kwargs):
zipfile = self.zipfile
if self.zipfile != self.__original_zipfile:
self.__original_zipfile.delete(save=False)
self.__original_zipfile.delete(save=False) # This clears both zip fields (original and current)
self.zipfile = zipfile # Needed to restore the newly uploaded zip file when replacing an old one
return super(ProblemData, self).save(*args, **kwargs)

def has_yml(self):
Expand All @@ -74,6 +92,209 @@ def _update_code(self, original, new):
self.save()
_update_code.alters_data = True

def setup_test_cases_content(self):
self.test_cases_content = ''

if self.zipfile:
zip = ZipFile(self.zipfile)

last = 0
content = []
test_cases = ProblemTestCase.objects.filter(dataset_id=self.problem.pk)

for i, tc in enumerate([x for x in test_cases if x.is_pretest]):
self.append_tescase_to_statement(zip, content, tc, i)
last = i + 1

for i, tc in enumerate([x for x in test_cases if not x.is_pretest]):
self.append_tescase_to_statement(zip, content, tc, i + last)

self.test_cases_content = '\n'.join(content)

def append_tescase_to_statement(self, zip, content, tc, i):
content.append(f'## Test Case {i+1}')

if tc.is_private:
content.append('*Hidden: this is a private test case!* ')

else:
content.append('### Input')
content.append('```')
if tc.input_file != '':
content.append(zip.read(tc.input_file).decode('utf-8'))
content.append('```')

content.append('')
content.append('### Output')
content.append('```')
if tc.output_file != '':
content.append(zip.read(tc.output_file).decode('utf-8'))
content.append('```')

if tc.explanation_file != '':
content.append('')
content.append('### Explanation')

if tc.explanation_file != '':
content.append(zip.read(tc.explanation_file).decode('utf-8'))

content.append('')

def infer_test_cases_from_zip(self):
# Just infers the zip data into ProblemTestCase objects, without changes in the database.
# It will try to mantain existing test cases data if the input and output entries are the same.
if not self.zipfile:
# The zip file will be loaded from the file system if not provided
files = problem_data_storage.listdir(self.problem.code)[1]
zipfiles = [x for x in files if '.zip' in x]

if len(zipfiles) > 0:
self.zipfile = _problem_directory_file(self.problem.code, zipfiles[0])
else:
raise FileNotFoundError

files = sorted(ZipFile(self.zipfile).namelist())
input = [x for x in files if '.in' in x or ('input' in x and '.' in x)]
output = [x for x in files if '.out' in x or ('output' in x and '.' in x)]

# Not all explanations are mandatory, so there can be gaps!
input.sort()
output.sort()
explanation = []
for i in range(len(input)):
in_file = input[i]

xpl_file = in_file.replace('input', 'explanation') if 'input' in in_file else in_file.replace('in', 'xpl')
found = [x for x in files if xpl_file in x]
found.sort()

if len(found) > 0:
explanation.append(found[0])
else:
explanation.append('')

cases = []
for i in range(len(input)):
list = ProblemTestCase.objects.filter(dataset_id=self.problem.pk, input_file=input[i],
output_file=output[i])
if len(list) >= 1:
# Multiple test-cases for the same data is allowed, but strange. Using object.get() produces an
# exception.
ptc = list[0]
else:
ptc = ProblemTestCase()
ptc.dataset = self.problem
ptc.is_pretest = False
ptc.is_private = False
ptc.order = i
ptc.input_file = input[i]
ptc.output_file = output[i]
ptc.explanation_file = explanation[i]
ptc.points = 0

cases.append(ptc)

return cases

def reload_test_cases_from_yml(self):
cases = []
if self.has_yml():
yml = problem_data_storage.open('%s/init.yml' % self.problem.code)
doc = yaml.safe_load(yml)

# Load same YML data as in site/judge/utils/problem_data.py -> ProblemDataCompiler()
if doc.get('archive'):
self.zipfile = _problem_directory_file(self.problem.code, doc['archive'])

if doc.get('generator'):
self.generator = _problem_directory_file(self.problem.code, doc['generator'])

if doc.get('pretest_test_cases'):
self.pretest_test_cases = doc['pretest_test_cases']

if doc.get('output_limit_length'):
self.output_limit = doc['output_limit_length']

if doc.get('output_prefix_length'):
self.output_prefix = doc['output_prefix_length']

if doc.get('unicode'):
self.unicode = doc['unicode']

if doc.get('nobigmath'):
self.nobigmath = doc['nobigmath']

if doc.get('checker'):
self.checker = doc['checker']

if doc.get('hints'):
for h in doc['hints']:
if h == 'unicode':
self.unicode = True
if h == 'nobigmath':
self.nobigmath = True

if doc.get('pretest_test_cases'):
cases += self._load_test_case_from_doc(doc, 'pretest_test_cases', True)

if doc.get('test_cases'):
cases += self._load_test_case_from_doc(doc, 'test_cases', False)

return cases

def _load_test_case_from_doc(self, doc, field, is_pretest):
cases = []
for i, test in enumerate(doc[field]):
ptc = ProblemTestCase()
ptc.dataset = self.problem
ptc.is_pretest = is_pretest
ptc.order = i

if test.get('type'):
ptc.type = test['type']

if test.get('in'):
ptc.input_file = test['in']

if test.get('out'):
ptc.output_file = test['out']

if test.get('xpl'):
ptc.explanation_file = test['xpl']

if test.get('points'):
ptc.points = test['points']
else:
ptc.points = 0

if test.get('is_private'):
ptc.is_private = test['is_private']

if test.get('generator_args'):
args = []
for arg in test['generator_args']:
args.append(arg)

ptc.generator_args = '\n'.join(args)

if test.get('output_prefix_length'):
ptc.output_prefix = doc['output_prefix_length']

if test.get('output_limit_length'):
ptc.output_limit = doc['output_limit_length']

if test.get('checker'):
chk = test['checker']
if isinstance(chk, str):
ptc.checker = chk
else:
ptc.checker = chk['name']
ptc.checker_args = chk['args']

cases.append(ptc)

return cases


class ProblemTestCase(models.Model):
dataset = models.ForeignKey('Problem', verbose_name=_('problem data set'), related_name='cases',
Expand All @@ -86,9 +307,11 @@ class ProblemTestCase(models.Model):
default='C')
input_file = models.CharField(max_length=100, verbose_name=_('input file name'), blank=True)
output_file = models.CharField(max_length=100, verbose_name=_('output file name'), blank=True)
explanation_file = models.CharField(max_length=100, verbose_name=_('explanation file name'), blank=True)
generator_args = models.TextField(verbose_name=_('generator arguments'), blank=True)
points = models.IntegerField(verbose_name=_('point value'), blank=True, null=True)
is_pretest = models.BooleanField(verbose_name=_('case is pretest?'))
is_pretest = models.BooleanField(verbose_name=_('case is pretest?'), default=False)
is_private = models.BooleanField(verbose_name=_('case is private?'), default=False)
output_prefix = models.IntegerField(verbose_name=_('output prefix length'), blank=True, null=True)
output_limit = models.IntegerField(verbose_name=_('output limit length'), blank=True, null=True)
checker = models.CharField(max_length=10, verbose_name=_('checker'), choices=CHECKERS, blank=True)
Expand Down
Loading
Loading