AtCoder参加時に自動化で楽をしていること紹介(.NET)

# はじめに
この記事は[競技プログラミングを始めたばかりの人に伝えたいことアドベントカレンダー](https://qiita.com/advent-calendar/2021/pre-competitive)1日目の記事です
アドベントカレンダーは初参加なので公開が楽しみです

# 概要
しばらく前からAtCoderに参加する時にプロジェクトディレクトリ作成とテストを自動で走らせています
これのやり方について書いていきます

# 環境
OS: Windows10
エディタ: Rider
使用言語: C#(.NET Core 3.1)

# プロジェクト作成
## 経緯
.NETのコンソールアプリケーションを作成するには以下の手順を踏みます

1. プロジェクトディレクトリ作成
2. `dotnet new console`コマンドでコンソールアプリケーションを作成

これだけなので十数秒で終わりますが、コンテスト中に実行していては時間がもったいないですし集中が途切れます
僕は問題毎にプロジェクトを作るのでD問題まで進んだとすると1分程度のロスです

そこで、コンテスト開始前にプロジェクトを作ることも試しましたがこれも僕には合いませんでした
コンテスト直前まで夕食を食べていたり歯磨きやトイレなどを済ませている間にプロジェクト作成の時間が無くなってしまうのです

そこで、バッチでプロジェクト作成をすることにしました。そうすれば、バッチを実行している間にのんびりトイレに行ったり、コンテストページを眺めたり出来ます

## やり方
ディレクトリ構造は以下の様にしました

```
<ルート>
├CloneTmpFolder.bat - 複製バッチ
├_tmp
  ├AtCoder
    ├Program.cs - 便利ライブラリ入りの解答用テンプレート
  ├Test
    ├UnitTest1.cs - 単体テストテンプレート
```

CloneTmpFolder.batの内容は以下の通りです
これを実行するとルート直下に`AtCoderBeginnerContestXXX`というディレクトリが作成され、その中にA~D問題用のプロジェクトとそれぞれに対して単体テストプロジェクトが生成されます
その後、ソリューションが読み込まれた状態でRiderが起動します
僕のPCではRiderの起動に十数秒掛かるので、これも自動で済ませておけます

``` bat
@echo off

setlocal enabledelayedexpansion

rem コンテストフォルダ作成
mkdir AtCoderBeginnerContestXXX
cd AtCoderBeginnerContestXXX

dotnet new sln --name AtCoder

rem 問題名の定義
set problemNames[0]=A
set problemNames[1]=B
set problemNames[2]=C
set problemNames[3]=D

rem 問題ごとに処理を繰り返す
for /l %%i in (0, 1, 3) do (
	set problemName=!problemNames[%%i]!
	
	rem 解答用プロジェクトを作成
	mkdir !problemName!
	cd !problemName!
	dotnet new console
	copy ..\..\_tmp\AtCoder\Program.cs
	cd ..
	dotnet sln add !problemName!

	rem テスト用プロジェクトを作成
	mkdir !problemName!Test
	cd !problemName!Test
	dotnet new mstest
	dotnet add reference ../!problemName!
	copy ..\..\_tmp\Test\UnitTest1.cs
	mkdir SampleInOut
	cd ..

	dotnet sln add !problemName!Test
)

rem Rider起動とソリューション読み込みまで済ませる。しかしcmdを閉じるとRiderが閉じる
rider AtCoder.sln

endlocal

```

これで、AtCoder参加用のソリューションの作成を自動化することが出来ました
AtCoderが始まる数分前にこのバッチを叩いておけばすぐにコーディングが出来る環境が完成していてとても快適です

# テスト
## テストコード
.NETで自動テストをするためのライブラリは様々あります
いくつか試しましたが、書き方が好みだったので[MSTest](https://docs.microsoft.com/ja-jp/dotnet/core/testing/unit-testing-with-mstest)を使っています

先程のバッチで単体テスト用プロジェクトは作成済みなのでそのプロジェクトのUnitTest1.csに以下のコードを書きました

``` cs
using System;
using System.IO;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using AtCoder;

namespace Test
{
	[TestClass]
	public class UnitTest1
	{
		[TestMethod]
		public void Test1()
		{
			const string pathOffset = "../../../SampleInOut/1/";
			TestInOut(pathOffset + "In.txt", pathOffset + "Out.txt");
		}
		
		[TestMethod]
		public void Test2()
		{
			const string pathOffset = "../../../SampleInOut/2/";
			TestInOut(pathOffset + "In.txt", pathOffset + "Out.txt");
		}
		
		[TestMethod]
		public void Test3()
		{
			const string pathOffset = "../../../SampleInOut/3/";
			TestInOut(pathOffset + "In.txt", pathOffset + "Out.txt");
		}
		
		[TestMethod]
		public void Test4()
		{
			const string pathOffset = "../../../SampleInOut/4/";
			TestInOut(pathOffset + "In.txt", pathOffset + "Out.txt");
		}
        
		private static void TestInOut(string inputFileName, string outputFileName)
		{
			if (!File.Exists(inputFileName)) return;
			
			using var input = new StreamReader(inputFileName);
			using var output = new StringWriter();
			Console.SetOut(output);
			Console.SetIn(input);

			SolveExecuter.Main();

			Assert.AreEqual(File.ReadAllText(outputFileName), output.ToString());
		}
	}
}
```

解答用プロジェクトの`SolveExecuter.Main`関数内に標準入出力処理を書いています
そして、テストコード内で標準入出力を行うことでわざわざ問題毎にテストコードをカスタムする手間を省いています

テストケースファイルは`/SampleInOut/<テストケース番号>`ディレクトリ以下に事前に格納します
ファイル名はIn.txt, Out.txtです

実行するには単体テスト用プロジェクトのディレクトリで以下のコマンドを実行します

```
dotnet test
```

すると以下の様に結果が表示されます

```
合計 1 個のテスト ファイルが指定されたパターンと一致しました。

テストの実行に成功しました。
テストの合計数: 4
     成功: 4
合計時間: 0.7363 秒
```

わざわざコマンドを実行するのは面倒くさいのでエディタ備え付けのテストエクスプローラーを使うと便利です
僕はRiderエディタを使っていて以下の様にテスト結果が表示されます。とても見やすいです
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/50ed2af6-610c-ec53-cd95-5c8d2c708b1c.png)

この様に手軽にテストを出来ることで、提出前に少なくともサンプル入出力はACできることを素早く確認することが出来ます
また、提出後にWAやTLEだった場合に安全に修正することが出来ます

## テストケース作成
前述の単体テストを実施する為には入出力をIn.txt, Out.txtというファイルにする必要があります
手作業で問題ページからサンプル入出力をコピペしても良いのですが、それだと時間がかかります
そこで、AtCoderの問題ページからサンプル入出力をテキストファイルとしてDLする為の.NETのコンソールアプリケーションを作成しました

実行してコンテストページのURLと何問目まで解く予定かを入力すれば自動でChromeが操作されてAtCoderにログイン後、サンプル入出力を問題毎にディレクトリを分けて保存してくれます。超便利！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413932/88352d3f-e878-0b11-1221-38fef5a4e5c8.png)

以下がソースコードです
Chrome操作には定番のSeleniumを使用しました

``` cs
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using OpenQA.Selenium;
using OpenQA.Selenium.Chrome;

namespace DownloadTestCases
{
	internal static class Program
	{
		private const string NAME = <AtCoderのユーザー名>;
		private const string PASS = <AtCoderのパスワード>;
		
		public static void Main(string[] args)
		{
			Console.WriteLine("コンテストページのURLを入力して下さい");
			string contestPageUrl = Console.ReadLine();
			var contestPageUrlSplit = contestPageUrl.Split('/').ToList();
			if (contestPageUrlSplit.Last().Contains("tasks"))
			{
				contestPageUrlSplit.RemoveAt(contestPageUrlSplit.Count - 1);
			}

			Console.WriteLine("何問目までのテストケースが必要ですか？");
			// ReSharper disable once AssignNullToNotNullAttribute
			int problemCnt = int.Parse(Console.ReadLine());

			IWebDriver driver = new ChromeDriver(Path.GetDirectoryName(Assembly.GetEntryAssembly().Location));
			try
			{
				for (int i = 0; i < problemCnt; i++)
				{
					char problemNameLower = (char)('a' + i);
					string contestName = contestPageUrlSplit.Last();
					var problemPageUrl = string.Join('/', contestPageUrlSplit);
					problemPageUrl += $"/tasks/{contestName}_{problemNameLower}";
					SaveTestCases(driver, problemPageUrl, char.ToUpper(problemNameLower).ToString());
				}
			}
			catch (Exception e)
			{
				Console.WriteLine(e);
				Console.WriteLine("終了するには何かキーを押して下さい");
				Console.ReadKey();
			}
			finally
			{
				driver.Quit();
			}
		}

		/// <summary>
		/// 問題ページからテストケースを読み取ってファイルに吐き出す
		/// </summary>
		/// <param name="driver"></param>
		/// <param name="urlString"></param>
		/// <param name="folderName"></param>
		/// <exception cref="Exception"></exception>
		private static void SaveTestCases(IWebDriver driver, string urlString, string folderName)
		{
			driver.Navigate().GoToUrl(urlString);
			
			// ログインが必要なページならログインする
			var h1S = driver.FindElements(By.TagName("h1"));
			if (h1S.Any(h1 => h1.Text == "404 Page Not Found"))
			{
				var loginButton = driver
					.FindElements(By.TagName("a")).ToList()
					.Find(ele => ele.Text.Equals("ログイン"));
				if (loginButton == null) throw new Exception("ログインが必要ですが、ログインボタンが見つかりません");
					
				loginButton.Click();

				// ログイン操作
				driver.FindElement(By.Id("username")).SendKeys(NAME);
				driver.FindElement(By.Id("password")).SendKeys(PASS);
				driver.FindElement(By.Id("submit")).Submit();
			}
			
			var samplesParentLangJa = driver
				.FindElement(By.Id("task-statement"))
				.FindElement(By.ClassName("lang-ja"));
			for (int i = 0; i < 4; i++)
			{
				try
				{
					var inEle = samplesParentLangJa.FindElement(By.Id("pre-sample" + (i * 2)));
					var outEle = samplesParentLangJa.FindElement(By.Id("pre-sample" + (i * 2 + 1)));

					var path = $@"SampleInOut\{folderName}\{i + 1}\";
					Directory.CreateDirectory(path);

					File.WriteAllText(path + "In.txt", inEle.Text + "\r\n");
					File.WriteAllText(path + "Out.txt", outEle.Text + "\r\n");
				}
				catch
				{
					// これ以上テストケースが無ければ終わり
					break;
				}
			}
		}
	}
}
```

# 終わりに
僕がAtCoderについて自動化していることは以上です
自動化する前はコンテスト前に5分くらい掛けてプロジェクトを作ることが面倒でコンテスト参加自体も億劫になることがありました
また、解答用コードを書き終わってサンプルテストケースでテストする作業も単純なコピペで嫌でした
しかし、自動化してからは解答用コードを書くことに集中できるようになりました。提出速度も速くなった気がします