はじめに
この記事は競技プログラミングを始めたばかりの人に伝えたいことアドベントカレンダー1日目の記事です
アドベントカレンダーは初参加なので公開が楽しみです
概要
しばらく前からAtCoderに参加する時にプロジェクトディレクトリ作成とテストを自動で走らせています
これのやり方について書いていきます
環境
OS: Windows10
エディタ: Rider
使用言語: C#(.NET Core 3.1)
プロジェクト作成
経緯
.NETのコンソールアプリケーションを作成するには以下の手順を踏みます
- プロジェクトディレクトリ作成
-
dotnet new console
コマンドでコンソールアプリケーションを作成
これだけなので十数秒で終わりますが、コンテスト中に実行していては時間がもったいないですし集中が途切れます
僕は問題毎にプロジェクトを作るのでD問題まで進んだとすると1分程度のロスです
そこで、コンテスト開始前にプロジェクトを作ることも試しましたがこれも僕には合いませんでした
コンテスト直前まで夕食を食べていたり歯磨きやトイレなどを済ませている間にプロジェクト作成の時間が無くなってしまうのです
そこで、バッチでプロジェクト作成をすることにしました。そうすれば、バッチを実行している間にのんびりトイレに行ったり、コンテストページを眺めたり出来ます
やり方
ディレクトリ構造は以下の様にしました
<ルート>
├CloneTmpFolder.bat - 複製バッチ
├_tmp
├AtCoder
├Program.cs - 便利ライブラリ入りの解答用テンプレート
├Test
├UnitTest1.cs - 単体テストテンプレート
CloneTmpFolder.batの内容は以下の通りです
これを実行するとルート直下にAtCoderBeginnerContestXXX
というディレクトリが作成され、その中にA~D問題用のプロジェクトとそれぞれに対して単体テストプロジェクトが生成されます
その後、ソリューションが読み込まれた状態でRiderが起動します
僕のPCではRiderの起動に十数秒掛かるので、これも自動で済ませておけます
@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を使っています
先程のバッチで単体テスト用プロジェクトは作成済みなのでそのプロジェクトのUnitTest1.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エディタを使っていて以下の様にテスト結果が表示されます。とても見やすいです
この様に手軽にテストを出来ることで、提出前に少なくともサンプル入出力はACできることを素早く確認することが出来ます
また、提出後にWAやTLEだった場合に安全に修正することが出来ます
テストケース作成
前述の単体テストを実施する為には入出力をIn.txt, Out.txtというファイルにする必要があります
手作業で問題ページからサンプル入出力をコピペしても良いのですが、それだと時間がかかります
そこで、AtCoderの問題ページからサンプル入出力をテキストファイルとしてDLする為の.NETのコンソールアプリケーションを作成しました
実行してコンテストページのURLと何問目まで解く予定かを入力すれば自動でChromeが操作されてAtCoderにログイン後、サンプル入出力を問題毎にディレクトリを分けて保存してくれます。超便利!
以下がソースコードです
Chrome操作には定番のSeleniumを使用しました
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分くらい掛けてプロジェクトを作ることが面倒でコンテスト参加自体も億劫になることがありました
また、解答用コードを書き終わってサンプルテストケースでテストする作業も単純なコピペで嫌でした
しかし、自動化してからは解答用コードを書くことに集中できるようになりました。提出速度も速くなった気がします