Repository: kmyk/online-judge-tools (python)

Issue:
add a feature to check MLE
MLEやらかした 検出されないのが悪い

Pull request description:
#312: add a feature to check MLE
close #312 

うっかり MLE しないようにメモリ使用量を監視する機能を足します。

具体的な仕様は次のようになります。

-   それぞれのテストケースで:
    -   メモリ使用量が 500MB より大きいなら目立たせて出力して警告
    -   メモリ使用量が 100MB より大きいならとりあえず出力
    -   メモリ使用量がそれ以下なら邪魔なので非表示 (オプションを指定で出力)
-   最後には常にメモリ使用量を出力 (同じ条件で必要なら目立たせて警告)

---

実装には GNU `time`  (`/usr/bin/time` とかにあるやつであって、shell builtin の `time` コマンドとは異なる) を利用しています。
メモリ使用量を監視するのは一般に面倒で (ある時刻の使用量でなくて実行開始から終了まででの全時刻の最大を取らないとだめ、監視対象のプロセスをきちんと指定しないとだめ、別プロセスのメモリ使用量を好き勝手に取得するのはセキュリティの問題がある)、 他のよい方法は思い付きませんでした。
もちろん `time` が常に存在するとは限らないですし、存在しても互換性の問題がないとも限らないので、とりあえず使ってみて動くかどうかを判断する処理も含んでいます。

@fukatani これもレビューが簡単ではないものですが、よろしくお願いします :bow:

Patch:
diff --git a/onlinejudge/_implementation/command/generate_output.py b/onlinejudge/_implementation/command/generate_output.py
index 8964bb1..a95d528 100644
--- a/onlinejudge/_implementation/command/generate_output.py
+++ b/onlinejudge/_implementation/command/generate_output.py
@@ -25,9 +25,9 @@ def generate_output_single_case(test_name: str, test_input_path: pathlib.Path, *
 
     # run the command
     with test_input_path.open() as inf:
-        begin = time.perf_counter()
-        answer, proc = utils.exec_command(args.command, stdin=inf, timeout=args.tle)
-        end = time.perf_counter()
+        info, proc = utils.exec_command(args.command, stdin=inf, timeout=args.tle)
+        answer = info['answer']  # type: Optional[bytes]
+        elapsed = info['elapsed']  # type: float
 
     # acquire lock to print logs properly, if in parallel
     nullcontext = contextlib.ExitStack()
@@ -37,7 +37,7 @@ def generate_output_single_case(test_name: str, test_input_path: pathlib.Path, *
             log.info('%s', test_name)
 
         # check the result
-        log.status('time: %f sec', end - begin)
+        log.status('time: %f sec', elapsed)
         if proc.returncode is None:
             log.failure(log.red('TLE'))
             log.info('skipped.')
@@ -46,6 +46,7 @@ def generate_output_single_case(test_name: str, test_input_path: pathlib.Path, *
             log.failure(log.red('RE') + ': return code %d', proc.returncode)
             log.info('skipped.')
             return
+        assert answer is not None
         log.emit(utils.snip_large_file_content(answer, limit=40, head=20, tail=10, bold=True))
 
         # find the destination path
diff --git a/onlinejudge/_implementation/main.py b/onlinejudge/_implementation/main.py
index 80a42b5..d939152 100644
--- a/onlinejudge/_implementation/main.py
+++ b/onlinejudge/_implementation/main.py
@@ -142,6 +142,8 @@ tips:
     subparser.add_argument('-t', '--tle', type=float, help='set the time limit (in second) (default: inf)')
     subparser.add_argument('-i', '--print-input', action='store_true', help='print input cases if not AC')
     subparser.add_argument('-j', '--jobs', metavar='N', type=int, help='specifies the number of jobs to run simultaneously  (default: no parallelization)')
+    subparser.add_argument('--print-memory', action='store_true', help='print the amount of memory which your program used, even if it is small enough')
+    subparser.add_argument('--gnu-time', help='used to measure memory consumption (default: "time")', default='time')
     subparser.add_argument('--no-ignore-backup', action='store_false', dest='ignore_backup')
     subparser.add_argument('--ignore-backup', action='store_true', help='ignore backup files and hidden files (i.e. files like "*~", "\\#*\\#" and ".*") (default)')
     subparser.add_argument('--json', action='store_true')
diff --git a/onlinejudge/_implementation/utils.py b/onlinejudge/_implementation/utils.py
index 1e2dabd..679efc1 100644
--- a/onlinejudge/_implementation/utils.py
+++ b/onlinejudge/_implementation/utils.py
@@ -13,6 +13,7 @@ import shlex
 import shutil
 import subprocess
 import sys
+import tempfile
 import time
 import urllib.parse
 from typing import *
@@ -134,21 +135,43 @@ def textfile(s: str) -> str:  # should have trailing newline
         return s + '\n'
 
 
-def exec_command(command: str, timeout: Optional[float] = None, **kwargs) -> Tuple[bytes, subprocess.Popen]:
-    try:
-        proc = subprocess.Popen(shlex.split(command), stdout=subprocess.PIPE, stderr=sys.stderr, **kwargs)
-    except FileNotFoundError:
-        log.error('No such file or directory: %s', command)
-        sys.exit(1)
-    except PermissionError:
-        log.error('Permission denied: %s', command)
-        sys.exit(1)
-    try:
-        answer, _ = proc.communicate(timeout=timeout)
-    except subprocess.TimeoutExpired:
-        proc.terminate()
-        answer = b''
-    return answer, proc
+def exec_command(command_str: str, *, stdin: IO[Any], timeout: Optional[float] = None, gnu_time: Optional[str] = None) -> Tuple[Dict[str, Any], subprocess.Popen]:
+    if gnu_time is not None:
+        context = tempfile.NamedTemporaryFile(delete=True)  # type: Any
+    else:
+        context = contextlib.ExitStack()  # TODO: we should use contextlib.nullcontext() if possible
+    with context as fh:
+        command = shlex.split(command_str)
+        if gnu_time is not None:
+            command = [gnu_time, '-f', '%M', '-o', fh.name, '--quiet', '--'] + command
+        begin = time.perf_counter()
+
+        try:
+            proc = subprocess.Popen(command, stdin=stdin, stdout=subprocess.PIPE, stderr=sys.stderr)
+        except FileNotFoundError:
+            log.error('No such file or directory: %s', command)
+            sys.exit(1)
+        except PermissionError:
+            log.error('Permission denied: %s', command)
+            sys.exit(1)
+        try:
+            answer, _ = proc.communicate(timeout=timeout)
+        except subprocess.TimeoutExpired:
+            proc.terminate()
+            answer = None
+
+        end = time.perf_counter()
+        if gnu_time is not None:
+            with open(fh.name) as fh1:
+                memory = int(fh1.read()) / 1000  # type: Optional[float]
+        else:
+            memory = None
+    info = {
+        'answer': answer,  # Optional[byte]
+        'elapsed': end - begin,  # float, in second
+        'memory': memory,  # Optional[float], in megabyte
+    }
+    return info, proc
 
 
 # We should use this instead of posixpath.normpath
diff --git a/onlinejudge/service/atcoder.py b/onlinejudge/service/atcoder.py
index dc5e473..53f88c5 100644
--- a/onlinejudge/service/atcoder.py
+++ b/onlinejudge/service/atcoder.py
@@ -251,7 +251,7 @@ class AtCoderContest(object):
         _, _, self._can_participate = soup.find('span', text=re.compile(r'^(Can Participate|参加対象): ')).text.partition(': ')
         _, _, self._rated_range = soup.find('span', text=re.compile(r'^(Rated Range|Rated対象): ')).text.partition(': ')
         penalty_text = soup.find('span', text=re.compile(r'^(Penalty|ペナルティ): ')).text
-        m = re.match(r'(Penalty|ペナルティ): (\d+)( minutes|分)', penalty_text)
+        m = re.match(r'(Penalty|ペナルティ): (\d+)( minutes?|分)', penalty_text)
         assert m
         self._penalty = datetime.timedelta(minutes=int(m.group(2)))
 
@@ -286,11 +286,28 @@ class AtCoderContest(object):
         tbody = soup.find('tbody')
         return [AtCoderProblem._from_table_row(tr) for tr in tbody.find_all('tr')]
 
-    def iterate_submissions_where(self, me: bool = False, problem_id: Optional[str] = None, language_id: Optional[LanguageId] = None, status: Optional[str] = None, user_glob: Optional[str] = None, order: Optional[str] = None, desc: bool = False, lang: Optional[str] = None, session: Optional[requests.Session] = None) -> Generator['AtCoderSubmission', None, None]:
+    def iterate_submissions_where(
+            self,
+            me: bool = False,
+            problem_id: Optional[str] = None,
+            language_id: Optional[LanguageId] = None,
+            status: Optional[str] = None,
+            user_glob: Optional[str] = None,
+            order: Optional[str] = None,
+            desc: bool = False,
+            lang: Optional[str] = None,
+            pages: Optional[Iterator[int]] = None,
+            session: Optional[requests.Session] = None,
+    ) -> Generator['AtCoderSubmission', None, None]:
         """
         :note: If you use certain combination of options, then the results may not correct when there are new submissions while crawling.
         :param status: must be one of `AC`, `WA`, `TLE`, `MLE`, `RE`, `CLE`, `OLE`, `IE`, `WJ`, `WR`, or `Judging`
         :param order: must be one of `created`, `score`, `source_length`, `time_consumption`, or `memory_consumption`
+        :param me: use the `.../submissions/me` page instead of `.../submission`
+        :param user_glob: is used as the value of `f.User` query parameter
+        :param language_id: is used as the value of `f.Language` query parameter
+        :param lang: must be one of `ja`, `en`
+        :param pages: is an iterator to list the page numbers to GET
         """
         assert status in (None, 'AC', 'WA', 'TLE', 'MLE', 'RE', 'CE', 'QLE', 'OLE', 'IE', 'WJ', 'WR', 'Judging')
         assert order in (None, 'created', 'score', 'source_length', 'time_consumption', 'memory_consumption')
@@ -316,7 +333,7 @@ class AtCoderContest(object):
 
         # get
         session = session or utils.get_default_session()
-        for page in itertools.count(1):
+        for page in pages or itertools.count(1):
             params_page = ({'page': str(page)} if page >= 2 else {})
             url = base_url + '?' + urllib.parse.urlencode({**params, **params_page})
             resp = _request('GET', url, session=session)
@@ -361,8 +378,18 @@ def _AtCoderProblemContentPartial_from_row(tr: bs4.Tag):
     assert problem is not None
     alphabet = tds[0].text
     name = tds[1].text
-    time_limit_msec = int(float(utils.remove_suffix(tds[2].text, ' sec')) * 1000)
-    memory_limit_byte = int(float(utils.remove_suffix(tds[3].text, ' MB')) * 1000 * 1000)  # TODO: confirm this is MB truly, not MiB
+    if tds[2].text.endswith(' msec'):
+        time_limit_msec = int(utils.remove_suffix(tds[2].text, ' msec'))
+    elif tds[2].text.endswith(' sec'):
+        time_limit_msec = int(float(utils.remove_suffix(tds[2].text, ' sec')) * 1000)
+    else:
+        assert False
+    if tds[3].text.endswith(' KB'):
+        memory_limit_byte = int(float(utils.remove_suffix(tds[3].text, ' KB')) * 1000)
+    elif tds[3].text.endswith(' MB'):
+        memory_limit_byte = int(float(utils.remove_suffix(tds[3].text, ' MB')) * 1000 * 1000)  # TODO: confirm this is MB truly, not MiB
+    else:
+        assert False
     if len(tds) == 5:
         assert tds[4].text.strip() in ('', 'Submit', '提出')
 
@@ -471,14 +498,24 @@ def _AtCoderProblemContent_parse_partial(soup: bs4.BeautifulSoup, problem: 'AtCo
             break
     else:
         assert False
-    time_limit_msec = int(float(utils.remove_suffix(utils.remove_prefix(time_limit, time_limit_prefix), ' sec')) * 1000)
+    if time_limit.endswith(' msec'):
+        time_limit_msec = int(utils.remove_suffix(utils.remove_prefix(time_limit, time_limit_prefix), ' msec'))
+    elif time_limit.endswith(' sec'):
+        time_limit_msec = int(float(utils.remove_suffix(utils.remove_prefix(time_limit, time_limit_prefix), ' sec')) * 1000)
+    else:
+        assert False
 
     for memory_limit_prefix in ('メモリ制限: ', 'Memory Limit: '):
         if memory_limit.startswith(memory_limit_prefix):
             break
     else:
         assert False
-    memory_limit_byte = int(float(utils.remove_suffix(utils.remove_prefix(memory_limit, memory_limit_prefix), ' MB')) * 1000 * 1000)
+    if memory_limit.endswith(' KB'):
+        memory_limit_byte = int(float(utils.remove_suffix(utils.remove_prefix(memory_limit, memory_limit_prefix), ' KB')) * 1000)
+    elif memory_limit.endswith(' MB'):
+        memory_limit_byte = int(float(utils.remove_suffix(utils.remove_prefix(memory_limit, memory_limit_prefix), ' MB')) * 1000 * 1000)
+    else:
+        assert False
 
     return AtCoderProblemContentPartial(alphabet, memory_limit_byte, name, problem, time_limit_msec)
 
@@ -723,7 +760,7 @@ class AtCoderSubmission(onlinejudge.type.Submission):
         self._submission_time = None  # type: Optional[datetime.datetime]
         self._user_id = None  # type: Optional[str]
         self._language_name = None  # type: Optional[str]
-        self._score = None  # type: Optional[int]
+        self._score = None  # type: Optional[float]
         self._code_size = None  # type: Optional[int]
         self._status = None  # type: Optional[str]
         self._exec_time_msec = None  # type: Optional[int]
@@ -746,7 +783,7 @@ class AtCoderSubmission(onlinejudge.type.Submission):
         self._problem_id = problem.problem_id
         self._user_id = tds[2].find_all('a')[0]['href'].split('/')[-1]
         self._language_name = tds[3].text
-        self._score = int(tds[4].text)
+        self._score = float(tds[4].text)
         self._code_size = int(utils.remove_suffix(tds[5].text, ' Byte'))
         self._status = tds[6].text
         if len(tds) == 10:
@@ -837,7 +874,7 @@ class AtCoderSubmission(onlinejudge.type.Submission):
         self._submission_time = datetime.datetime.strptime(data['Submission Time'], '%Y-%m-%d %H:%M:%S+0900').replace(tzinfo=utils.tzinfo_jst)
         self._user_id = data['User']
         self._language_name = data['Language']
-        self._score = int(data['Score'])
+        self._score = float(data['Score'])
         self._code_size = int(utils.remove_suffix(data['Code Size'], ' Byte'))
         self._status = data['Status']
         if 'Exec Time' in data:
@@ -888,7 +925,7 @@ class AtCoderSubmission(onlinejudge.type.Submission):
     get_user_id = utils.getter_with_load_details('_user_id', type=str)  # type: Callable[..., str]
     get_submission_time = utils.getter_with_load_details('_submission_time', type=datetime.datetime)  # type: Callable[..., datetime.datetime]
     get_language_name = utils.getter_with_load_details('_language_name', type=str)  # type: Callable[..., str]
-    get_score = utils.getter_with_load_details('_score', type=int)  # type: Callable[..., int]
+    get_score = utils.getter_with_load_details('_score', type=float)  # type: Callable[..., int]
     get_code_size = utils.getter_with_load_details('_code_size', type=int)  # type: Callable[..., int]
     get_status = utils.getter_with_load_details('_status', type=str)  # type: Callable[..., str]
     get_test_sets = utils.getter_with_load_details('_test_sets', type='List[AtCoderSubmissionTestSet]')  # type: Callable[..., List[AtCoderSubmissionTestSet]]
@@ -898,12 +935,12 @@ class AtCoderSubmission(onlinejudge.type.Submission):
 class AtCoderSubmissionTestSet(object):
     """
     :ivar set_name: :py:class:`str`
-    :ivar score: :py:class:`int`
-    :ivar max_score: :py:class:`int`
+    :ivar score: :py:class:`float`
+    :ivar max_score: :py:class:`float`
     :ivar test_case_names: :py:class:`List` [ :py:class:`str` ]
     """
 
-    def __init__(self, set_name: str, score: int, max_score: int, test_case_names: List[str]):
+    def __init__(self, set_name: str, score: float, max_score: float, test_case_names: List[str]):
         self.set_name = set_name
         self.score = score
         self.max_score = max_score
@@ -914,7 +951,7 @@ class AtCoderSubmissionTestSet(object):
         tds = tr.find_all('td')
         assert len(tds) == 3
         set_name = tds[0].text
-        score, max_score = [int(s) for s in tds[1].text.split('/')]
+        score, max_score = [float(s) for s in tds[1].text.split('/')]
         test_case_names = tds[2].text.split(', ')
         return AtCoderSubmissionTestSet(set_name, score, max_score, test_case_names)
 


Test patch:
diff --git a/onlinejudge/_implementation/command/test.py b/onlinejudge/_implementation/command/test.py
index 922f8af..e551284 100644
--- a/onlinejudge/_implementation/command/test.py
+++ b/onlinejudge/_implementation/command/test.py
@@ -6,8 +6,10 @@ import math
 import pathlib
 import subprocess
 import sys
+import tempfile
 import threading
 import time
+import traceback
 from typing import *
 
 import onlinejudge
@@ -18,6 +20,9 @@ import onlinejudge._implementation.utils as utils
 if TYPE_CHECKING:
     import argparse
 
+MEMORY_WARNING = 500  # megabyte
+MEMORY_PRINT = 100  # megabyte
+
 
 def compare_as_floats(xs_: str, ys_: str, error: float) -> bool:
     def f(x):
@@ -129,12 +134,11 @@ def test_single_case(test_name: str, test_input_path: pathlib.Path, test_output_
 
     # run the binary
     with test_input_path.open() as inf:
-        begin = time.perf_counter()
-        answer_byte, proc = utils.exec_command(args.command, stdin=inf, timeout=args.tle)
-        end = time.perf_counter()
-        elapsed = end - begin
-        answer = answer_byte.decode()  # TODO: the `answer` should be bytes, not str
-        proc.terminate()
+        info, proc = utils.exec_command(args.command, stdin=inf, timeout=args.tle, gnu_time=args.gnu_time)
+        # TODO: the `answer` should be bytes, not str
+        answer = (info['answer'] or b'').decode()  # type: str
+        elapsed = info['elapsed']  # type: float
+        memory = info['memory']  # type: Optional[float]
 
     # lock is require to avoid mixing logs if in parallel
     nullcontext = contextlib.ExitStack()  # TODO: use contextlib.nullcontext() after updating Python to 3.7
@@ -143,6 +147,14 @@ def test_single_case(test_name: str, test_input_path: pathlib.Path, test_output_
             log.emit('')
             log.info('%s', test_name)
         log.status('time: %f sec', elapsed)
+        if memory:
+            if memory < MEMORY_PRINT:
+                if args.print_memory:
+                    log.status('memory: %f MB', memory)
+            elif memory < MEMORY_WARNING:
+                log.status('memory: %f MB', memory)
+            else:
+                log.warning('memory: %f MB', memory)
 
         status = compare_and_report(proc, answer, test_input_path, test_output_path, mode=args.mode, error=args.error, does_print_input=args.print_input, silent=args.silent, rstrip=args.rstrip)
 
@@ -153,14 +165,32 @@ def test_single_case(test_name: str, test_input_path: pathlib.Path, test_output_
     }
     if test_output_path:
         testcase['output'] = str(test_output_path.resolve())
-    result = {
+    return {
         'status': status,
         'testcase': testcase,
         'output': answer,
         'exitcode': proc.returncode,
         'elapsed': elapsed,
+        'memory': memory,
     }
-    return result
+
+
+def check_gnu_time(gnu_time: str) -> bool:
+    try:
+        with tempfile.NamedTemporaryFile(delete=True) as fh:
+            proc = subprocess.run([gnu_time, '-f', '%M KB', '-o', fh.name, '--quiet', '--', 'true'])
+            assert proc.returncode == 0
+            with open(fh.name) as fh1:
+                data = fh1.read()
+            int(utils.remove_suffix(data.rstrip(), ' KB'))
+            return True
+    except NameError:
+        raise  # NameError is not a runtime error caused by the environmet, but a coding mistake
+    except AttributeError:
+        raise  # AttributeError is also a mistake
+    except Exception as e:
+        log.debug(traceback.format_exc())
+    return False
 
 
 def test(args: 'argparse.Namespace') -> None:
@@ -171,6 +201,11 @@ def test(args: 'argparse.Namespace') -> None:
         args.test = fmtutils.drop_backup_or_hidden_files(args.test)
     tests = fmtutils.construct_relationship_of_files(args.test, args.directory, args.format)
 
+    # check wheather GNU time is available
+    if not check_gnu_time(args.gnu_time):
+        log.warning('GNU time is not available: %s', args.gnu_time)
+        args.gnu_time = None
+
     # run tests
     history = []  # type: List[Dict[str, Any]]
     if args.jobs is None:
@@ -186,8 +221,10 @@ def test(args: 'argparse.Namespace') -> None:
                 history += [future.result()]
 
     # summarize
-    slowest = -1  # type: Union[int, float]
+    slowest = -1.0  # type: float
     slowest_name = ''
+    heaviest = -1.0  # type: float
+    heaviest_name = ''
     ac_count = 0
     for result in history:
         if result['status'] == 'AC':
@@ -195,10 +232,18 @@ def test(args: 'argparse.Namespace') -> None:
         if slowest < result['elapsed']:
             slowest = result['elapsed']
             slowest_name = result['testcase']['name']
+        if result['memory'] is not None and heaviest < result['memory']:
+            heaviest = result['memory']
+            heaviest_name = result['testcase']['name']
 
     # print the summary
     log.emit('')
     log.status('slowest: %f sec  (for %s)', slowest, slowest_name)
+    if heaviest >= 0:
+        if heaviest < MEMORY_WARNING:
+            log.status('max memory: %f MB  (for %s)', heaviest, heaviest_name)
+        else:
+            log.warning('max memory: %f MB  (for %s)', heaviest, heaviest_name)
     if ac_count == len(tests):
         log.success('test ' + log.green('success') + ': %d cases', len(tests))
     else:
diff --git a/tests/command_test.py b/tests/command_test.py
index ea24492..6f7457e 100644
--- a/tests/command_test.py
+++ b/tests/command_test.py
@@ -455,3 +455,36 @@ class TestTest(unittest.TestCase):
             files=files,
             expected=expected,
         )
+
+    def test_call_test_large_memory(self):
+        # make a bytes of 100 MB
+        data = self.snippet_call_test(
+            args=['-c', """python -c 'print(len(b"A" * 100000000))'"""],
+            files=[
+                {
+                    'path': 'test/sample-1.in',
+                    'data': 'foo\n'
+                },
+            ],
+            expected=None,
+        )
+        for case in data:
+            self.assertEqual(case['status'], 'AC')
+            self.assertGreater(case['memory'], 100)
+            self.assertLess(case['memory'], 1000)
+
+    def test_call_test_small_memory(self):
+        # just print "foo"
+        data = self.snippet_call_test(
+            args=['-c', """python -c 'print("foo")'"""],
+            files=[
+                {
+                    'path': 'test/sample-1.in',
+                    'data': 'foo\n'
+                },
+            ],
+            expected=None,
+        )
+        for case in data:
+            self.assertEqual(case['status'], 'AC')
+            self.assertLess(case['memory'], 100)
diff --git a/tests/service_atcoder.py b/tests/service_atcoder.py
index cb8c00b..8c8c93c 100644
--- a/tests/service_atcoder.py
+++ b/tests/service_atcoder.py
@@ -59,6 +59,10 @@ class AtCoderContestTest(unittest.TestCase):
         self.assertEqual(contest.get_rated_range(), '-')
         self.assertEqual(contest.get_penalty().total_seconds(), 5 * 60)
 
+    def test_get_penalty_a_singular_form(self):
+        contest = AtCoderContest.from_url('https://atcoder.jp/contests/chokudai_S002')
+        self.assertEqual(contest.get_penalty().total_seconds(), 60)  # Penalty is written as "1 minute", not  "1 minutes"
+
     def test_list_problems(self):
         contest = AtCoderContest.from_url('https://atcoder.jp/contests/agc028')
         problems = contest.list_problems()
@@ -85,6 +89,21 @@ class AtCoderContestTest(unittest.TestCase):
         self.assertEqual(problems[1].get_time_limit_msec(), 5252)
         self.assertEqual(problems[1].get_memory_limit_byte(), int(525.252 * 1000 * 1000))
 
+    def test_list_problems_time_limit_is_less_than_msec(self):
+        contest = AtCoderContest.from_url('https://atcoder.jp/contests/joi2019ho')
+        problems = contest.list_problems()
+        self.assertEqual(problems[0].get_time_limit_msec(), 1000)
+        self.assertEqual(problems[1].get_time_limit_msec(), 1000)
+        self.assertEqual(problems[2].get_time_limit_msec(), 500)
+        self.assertEqual(problems[3].get_time_limit_msec(), 1000)
+        self.assertEqual(problems[4].get_time_limit_msec(), 2000)
+
+    def test_list_problems_memory_limit_is_zero(self):
+        contest = AtCoderContest.from_url('https://atcoder.jp/contests/future-contest-2019-final-open')
+        problems = contest.list_problems()
+        self.assertEqual(problems[0].get_memory_limit_byte(), 1024 * 1000 * 1000)  # 1024 MB
+        self.assertEqual(problems[1].get_memory_limit_byte(), 0)  # 0 KB
+
     def test_iterate_submissions(self):
         contest = AtCoderContest.from_url('https://atcoder.jp/contests/code-festival-2014-exhibition-open')
         submissions = list(contest.iterate_submissions())
@@ -135,6 +154,13 @@ class AtCoderProblemTest(unittest.TestCase):
 
         self.assertIsNone(AtCoderProblem.from_url('https://atcoder.jp/contests/wupc2019/tasks/wupc2019_a').get_score())
 
+    def test_get_time_limit_is_less_than_msec(self):
+        self.assertEqual(AtCoderProblem.from_url('https://atcoder.jp/contests/joi2019ho/tasks/joi2019ho_c').get_time_limit_msec(), 500)
+        self.assertEqual(AtCoderProblem.from_url('https://atcoder.jp/contests/future-contest-2019-qual/tasks/future_contest_2019_qual_b').get_time_limit_msec(), 0)
+
+    def test_get_memory_limit_is_zero(self):
+        self.assertEqual(AtCoderProblem.from_url('https://atcoder.jp/contests/future-contest-2019-qual/tasks/future_contest_2019_qual_b').get_memory_limit_byte(), 0)
+
     def test_iterate_submissions(self):
         problem = AtCoderProblem.from_url('https://atcoder.jp/contests/abc119/tasks/abc119_c')
         submissions = problem.iterate_submissions()
@@ -207,6 +233,10 @@ class AtCoderSubmissionTest(unittest.TestCase):
         self.assertEqual(submission.get_source_code(), b'/9\\|\\B/c:(\r\ncYay!\r\n')
         self.assertEqual(submission.get_code_size(), 19)
 
+    def test_get_score_float(self):
+        submission = AtCoderSubmission.from_url('https://atcoder.jp/contests/pakencamp-2018-day3/submissions/4583531')
+        self.assertAlmostEqual(submission.get_score(), 32.53)
+
 
 class AtCoderProblemContentTest(unittest.TestCase):
     def test_from_html(self):