[Atcoder][C++]コンテスト中に使えるテスト自動化ツールを作ってみた

趣味でAtcoder(競技プログラミング)やってますが最近伸び悩んでいます。
コンテスト中のテスト作業を自動化したらいい感じになるんじゃないかと思い、
テストの自動化ツールをpythonで作ってみました。
## はじめに
この記事で紹介する自作のツールよりも、多機能なものが下記にあります。

* https://github.com/kyuridenamida/atcoder-tools 
* https://github.com/nodchip/OnlineJudgeHelper

使われる際は上記も比較検討ください。
## 1.実現したこと
* Atcoder問題ページからテストケースをサーバーに負荷を掛けない範囲で自動取得する
* 作成したコード(c++)を提出前にビルド、採点し、AC,WA,TLE等を判定する
* 自分で独自にテストケースを追加し、自動採点の対象に追加する

**ソースコード**
https://github.com/tks3210/autoJudge

**動作環境**

* OS: Windows10 / Mac(Mojave 10.14.6)
* C++コンパイラ: gcc(多分clangでも動く)
* Python:3.7.1
  * requests, bs4, lxmlを使用

## 2.動作例



* 自動テスト
* 正誤判定(WA,TLE,CE)
* 自動認証(コンテスト中のログイン自動化)
* テストケース追加

### 2.1 自動テスト

**>python autojudge.py [contest_name] [quest]**
↑のように、コンテスト名(abc143)と問題名(abc143_a)を指定すると、
自動でビルドしてサンプル入出力データを取得してテストやってくれます。
色使いも本家と合わせて雰囲気出してみました。
![AC__.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/284450/c48964c6-d523-164d-71e7-635da11845b0.png)
上記のテストケース3種は、
https://atcoder.jp/contests/abc143/tasks/abc143_a
の入力例・出力例から自動で取得してます。

自分のテストによりAtcoderのサーバー様にご迷惑をかける訳にはいかないので、
一つの問題につきアクセスは１回にしてます。
（取得したテストケースをローカルに保存しておき、次回以降のテスト時にはそちらを参照するようにしてます。）

### 2.2 誤判定

間違ってるコードを書いたときに間違ってると教えてくれます。

* <font color="Orange">WA</font> (答えが間違っているよ)
* <font color="Orange">TLE</font> (計算時間が長すぎるよ)
* <font color="Orange">CE</font> (コンパイルすら通ってないぞ)

**WA(答え誤り)の場合**
![WA__.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/284450/20e2db21-87b6-46a7-9dd2-7226ee35c020.png)
**TLE(計算時間オーバー)の場合**
現状の設定では、結果が出力されるまで**2秒**を超えたらTLE扱いとしてます。
無限ループに走った場合も3秒くらいで打ち切って次のテストをしてくれます。
![TLE__.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/284450/efe46f39-475f-9a8a-6fc1-34bd7ddca810.png)
**CE(コンパイルエラー)の場合**
そもそもコンパイル通らないのは話にならないのでテストせずに終了します。
![CE__.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/284450/5cbb7dc9-0327-2233-7f19-6e51e5dd04cf.png)


### 2.3 自動認証

コンテスト開催中にこのテストを実行する場合、ログイン処理を行わないと問題ページを取得できないです。
-iオプションで、ユーザー名/パスワード等を設定することで、コンテスト中の自動認証とテスト実行を可能にしました。

```
>python autojudge.py abc143 abc143_d -i
Atcoder Username:tks_fj　  ※Atcoderアカウントのユーザー名
Atcoder Password:*******　 ※Atcoderアカウントのパスワード
Src Directory(Ex. ./aaa/abc140/abc140.cpp => input "./aaa"):../../02_contest
Judging abc143/abc143_d...
testcase 1: AC
testcase 2: AC
testcase 3: AC
result: AC
```
**この設定情報はローカルの設定ファイル(setting.conf)に保持されるので、この作業は一回きりでOKです。**
（この作業は直接設定ファイルを書き換えてもできます。）

### 2.4 テストケース追加

新しいテストケースを対話的に追加することも可能です
複数行の入力、出力に対応するため、"quit" + Enterで抜けるようにしています。

```
(入力: 20 7 出力: 6 を追加する)
>python autojudge.py abc143 abc143_a -a
type test input(exit by "quit")
20 7
quit
type test output(exit by "quit")
6
quit
```

再びテストをすると、testcase4に追加されます。

```
>python autojudge.py abc143 abc143_a
Judging abc143/abc143_a...
testcase 1: AC
testcase 2: AC
testcase 3: AC
testcase 4: AC
result: AC
```

**初期設定は[autoJudge:初期設定](https://github.com/tks3210/autoJudge)か次章を参考ください。**


## 3. 設計・実装

忘備録も兼ねて、中の設計・実装とか紹介します。
### 3.1 ディレクトリ構成

**autoJudge**というリポジトリで下記の成果物を管理してます。

* **autojudge.py (プログラムの本体)**
* **setting.conf (設定ファイル)**
* **testcase/ (テストケース。実行時に生成)**

テスト対象のソースコード(.cpp)が格納されたディレクトリで**autoJudge**がcloneされることを想定しており、
下記のようなディレクトリ構成と命名規則での動作を推奨してます。
（が、別に↓じゃなくても動くようにしてます。(4.補足の-pオプション を参照))

```
.
├── autoJudge ※今回作成した成果物
│   ├── setting.conf ※設定ファイル
│   ├── autojudge.py ※プログラム本体
│   ├── testcase ※テストケース一覧(テスト時に生成)
│   │   ├── abc142@abc142_a.txt
│   │   └── abc143@abc143_a.txt 
│   └── design.pu ※クラス図
├── abc142 ※コンテスト名
│   ├── abc142_a.cpp　※問題名.cpp
│   ├── abc142_b.cpp
│   ├── abc142_c.cpp
│   ├── abc142_d.cpp
│   ├── abc142_e.cpp
│   └── abc142_f.cpp
├── abc143
:
```

### 3.2 設定ファイル(setting.conf)

認証とかソースの場所の検索に用いる設定ファイルです。

```conf:setting.conf
username:tks_fj
password:*******
srcpath:../
```
* username/passwordにユーザ名/パスワードを記載(ログイン時に必要)
* srcpathにautoJudge.pyから「コンテスト名/問題名」への相対パスを記載

**2.3 自動認証**で紹介した-iオプションで設定可能ですが、もちろん直接書き換えてもＯＫです

### 3.3 プログラム本体(autojudge.py)


#### 3.3.1 クラス
プログラムは下記の二つのクラスで構成される。

* **ManageTestCases**: テストケースを取得、管理する
* **ExecuteTestCases**: テストケースに基づいてテストを実行する
![autojudge.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/284450/13524dbb-fa84-c601-5829-6d572945ee23.png)

#### 3.3.2 ManageTestCases: テスト管理クラス

* **RegisterUser() :設定ファイル(setting.conf)の編集**  
 * 標準入力からユーザー名、パスワード、ソースコードのパスを受け取って設定ファイルを更新する。

```python:autojudge.py
    def RegisterUser(self):
        """user設定(初回)"""

        print("Atcoder Username:", end="")
        username = input().rstrip('\r\n')
        print("Atcoder Password:", end="")
        password = input().rstrip('\r\n')
        print("Src Directory(Ex. ./aaa/abc140/abc140.cpp => input \"./aaa\"):", end="")
        srcpath = input().rstrip('\r\n')

        with open(CONF_FILE, "w") as f:
            f.write("username:" + username + "\n")
            f.write("password:" + password + "\n")
            f.write("srcpath:" + srcpath + "\n")
```

* **GetTestCases() :テストケースの取得**
  * 初回実行時にコンテスト名・問題名から問題ページのURIを生成し、テストケースを取得しファイルに記録する
  * 二回目以降の実行の際はファイルを読み、テストケースを取得する。
  * テストケース(testcases)およびコンテスト名・問題名の情報(testinfo)を返す。

```python:autojudge.py
    def GetTestCases(self, test_name, islogin = False):
        """指定された問題名からテストケースを取得しリストを返す"""

        self.__UpdateConf()
        file_name = self.contest + "@" + test_name + ".txt"
        testinfo = [{"contest":self.contest, "testname":test_name}]
        # サーバ負荷低減のため同一情報の取得はスクレイピングさせない
        if file_name in os.listdir(TESTCASES_PATH):
            testcases = self.__ReadFile(file_name)
        else:
            testcases = self.__ScrapePage(test_name, islogin)
            self.__WriteFile(file_name, testcases)
        return testinfo + testcases
```

* **AddTestCases() :テストケースの追加**
 * 指定された問題のテストケース(入力、出力)を対話形式で追加する。
 * 複数行のテストケースを受付("quit" + Enterで終了する)

```python:autojudge.py
    def AddTestCases(self, test_name):
        """取得したテストケースに独自のテストケースを追加する"""

        self.__UpdateConf()
        testcase = {}
        print("type test input(exit by \"quit\")")
        testcase["input"] = ""
        while(1):
            line = input()
            if (line == "quit"):
                break;
            testcase["input"] += line + "\n"
        print("type test output(exit by \"quit\")")
        testcase["output"] = ""
        while(1):
            line = input()
            if (line == "quit"):
                break;
            testcase["output"] += line + "\n"
        file_name = self.contest + "@" + test_name + ".txt"
        if file_name in os.listdir(TESTCASES_PATH):
            testcases = self.__ReadFile(file_name)
        testcases.append(testcase)
        self.__WriteFile(file_name, testcases)
```

#### 3.3.3 ExecuteTestCases: テスト実行クラス

* **Execute() : テストケースを実行**
    * ビルド(\_\_Build)が通れば実行(\_\_Run)し、最後に結果を出力(\_\_Result)する(AC, WA, TLE) 
    * ソースの相対パスが設定されていなければ、設定ファイル等から自動生成する。
    * __Run内部で子プロセスを起動し、実行時間を計測することでTLEを検出している。
     
```python:autojudge.py
    def Execute(self, srcpath = ""):
        """テストを実行"""

        print(YELLOW + "Judging " + self.testinfo["contest"] + "/" + self.testinfo["testname"] + "..." + COLORRESET)
        if (srcpath == ""):
            srcpath = self.__GetPath()
        self.__Build(srcpath)
        if (self.result["build"] == 0):
            self.__Run()
        self.__Result()
```

### 3.4 テストケース(testcase/)

* テスト初回実行にtestcase下にテキストファイルが生成される。(contest名@問題名.txt)
* フォーマットは下記
 
```text:abc143@abc143_a.txt
[test case 0]
---input---
12 4
---output---
4
---fin---
[test case 1]
---input---
20 15
---output---
0
---fin---
```

### 3.5 結果表示

* テストケースごとの結果と総合結果を表示する。
* ACは緑色、WA, TLE, CEは黄色で表示する。
* WA(結果の誤り)は予想と結果を出力する。
![WA__.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/284450/eda30a88-e825-3df6-cc1e-9092b7fb8e01.png)

## 4.補足

### 4.1 python環境に関して

下記のモジュールが必要になります。pipやらpip3やらcondaやらで直ぐ入れられます。

```
pip3 install requests
pip3 install bs4, lxml
```

### 4.2 オプション一覧
* -aでテストケース追加
* -iで初期設定(設定ファイル更新)
* -pでソースコードのパスを直接指定

```
>python autojudge.py --help
usage: autojudge.py [-h] [-p PATH] [-a] [-i] contest_name question

positional arguments:
  contest_name          set contest name(ex. abc143)
  question              set question name(ex. abc143_a)

optional arguments:
  -h, --help            show this help message and exit
  -p PATH, --path PATH  set path of source code
  -a, --addtest         add testcase
  -i, --init            set configuration
```

### 4.3 パス指定に関して(-p)

ソースコードのディレクトリ構成が、「3.1 ディレクトリ構成」ではない場合
（例えば、以下のような場合）

```
.
├── 02_contest
│   ├── abc143
│   │   ├── abc143_a.cpp
│   │   ├── abc143_b.cpp
│   │   └── abc143_c.cpp
├── 04_autotest
│   ├── autoJudge
│   │   ├── README.md
│   │   ├── autoJudge.py
│   │   ├── autojudge.pu
│   │   ├── setting.conf
│   │   └── testcase
```
-pオプションでソースコードの場所を直接指定できます。

```
>python autojudge.py abc143 abc143_a -p ../../02_contest/abc143/abc143_a.cpp
Judging abc143/abc143_a...
testcase 1: AC
testcase 2: AC
testcase 3: AC
testcase 4: AC
result: AC
```

### 4.4 既知の問題点
* 初回実行時にTLEとなる場合がある(scrapingの時間も含んでしまっている？？)
* **誤差一定以下を正解(AC)とする問題に未対応**
* TLEの判定精度が微妙？
* TLE時にPermissionErrorが出力される(Win10環境のみ)
* 実行時エラー(RE)に未対応
    * たまにバグる

## 5.あとがき

* plantUMLはべんりだった（クラス図生成に使用）
* BeutifulSoupもべんりだった（HTML解析に使用）
* argperseもややべんり（オプション解析に使用）
* 先駆者様はいっぱいいた
    * https://github.com/kyuridenamida/atcoder-tools 
    * https://github.com/nodchip/OnlineJudgeHelper
* はやく青色になりたい