Rubyで競プロのインタラクティブな問題をコードテスト

## したいこと
競技プログラミングでたまに「インタラクティブな問題」が出るが、それをコードテストのページで動作確認したい。

その際、提出する Ruby のコードは `gets` や `puts` などを使ったままとし、対話を模倣できるコードを一時的に追加するだけとしたい。（コードテストのページでファイル分割できないため）

```diff_ruby
 n = gets.to_i
 
+#--- ここからテスト用 ---#
+...
+#--- ここまでテスト用 ---#
 
 ...
 
 puts "! #{p0}"
 $stdout.flush
```

## 例題

https://atcoder.jp/contests/abc299/tasks/abc299_d

### 解答
二分探索でサクッと。

```ruby:abc299_d.rb
n = gets.to_i

p0, p1 = 1, n

while p1 - p0 > 1
  pm = (p0 + p1) / 2
  puts "? #{pm}"
  $stdout.flush

  v = gets.to_i
  if v == 0
    p0 = pm
  else
    p1 = pm
  end
end

puts "! #{p0}"
$stdout.flush
```

## 動作確認
### 手動で対話する
手動で動作確認するなら、単純にターミナル上でプログラムを実行して、出力に応じて入力していく。

![interactive_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/127130/b424c70d-10a7-8a3c-85bc-885c7a61344d.png)

設問ページの入出力例と同じく N = 7, S = 0010011 と決めて応答していくと、きちんと `! 5` と解答された。（出力は例と異なることが普通なため、応答の入力も例とは変えなければならない）

```shell-session
$ ruby abc299_d.rb
7
? 4
0
? 5
0
? 6
1
! 5
```

ちなみにプログラムの出力が事前にわかるのなら、それらへの応答を最初から全て標準入力に与えることで、コードテストのページでも動作確認できる。とはいえ応答を自力で用意しないといけないので、色々なパターンをすぐ試すのは難しい。

![custom_test_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/127130/4835309c-19a1-ad07-d641-6323d1069013.png)

### 自動で対話する（別ファイル）
上で手動でしたことを自動で行うスクリプトを作成する。以下のようにコマンドライン引数で解答プログラムを指定し、 `N` のような単純なパラメータだけ標準入力から与えて実行したい。

```shell-session
$ ruby test_abc299_d.rb abc299_d.rb <<< "200000"
```

[`IO.popen`](https://docs.ruby-lang.org/ja/3.3/method/IO/s/popen.html) を利用することで、中でプログラムを動かして入出力を繋げられる。

![interactive_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/127130/3cda2533-53ea-89bd-ba7c-a6314c8da14e.png)

```ruby:test_abc299_d.rb
# `ruby abc299_d.rb` を実行し、そこへ入出力できる IO オブジェクトを作成する
# （後の都合で、入力用と出力用とで変数を変えておく）
p_in = p_out = IO.popen("ruby #{ARGV[0]}", "r+")

# デバッグ用に、値をすぐ標準エラー出力へ出せるようにする
class Object
  def d
    $stderr.puts self
    self
  end
end

# 標準入力から問題を設定（※ ARGF から読み込まないよう注意）
n = $stdin.gets.to_i

# 出題
p_in.puts n.d
p_in.flush

# 隠しパラメータを設定
s = Array.new(n + 1) { rand(2) }.join
s[1] = "0"
s[n] = "1"

q = 20  # 質問可能な残り回数
while (t, line = p_out.gets.d.split(nil, 2))
  case t
  when "!"
    # 解答を正誤判定して終了
    v = line.to_i
    ac = (1...n).cover?(v) && s[v] != s[v+1]
    if ac
      exit
    else
      raise "WA"
    end
  when "?"
    # 質問に応答
    raise "WA" if q == 0  # 質問回数超過
    q -= 1
    v = line.to_i
    if (1..n).cover?(v)
      p_in.puts s[v].d
      p_in.flush
    else
      raise "WA"  # 不正な出力
    end
  else
    # 不正な出力
    raise "WA"
  end
end

# 解答せず終了
raise "WA"
```

### 自動で対話する（同一ファイル）
当初の目的だった、コードテストのページで実行したいもの。

スクリプトファイルを分けられない実行環境では、前の方法は使えない。

そこでテスト用のスクリプトは別スレッドで動かすように埋め込む（提出時は削除）。標準入出力に関しては `$stdin` と `$stdout` の IO オブジェクトをすり替えることで対処できる。
https://docs.ruby-lang.org/ja/3.3/method/Kernel/v/stdin.html

![interactive_3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/127130/0dc58dcf-17e3-d264-0d73-f5f50982595a.png)

```ruby:abc299_d_with_test.rb
n = gets.to_i

#--- ここからテスト用 ---#
# 標準入出力をすり替え、対話テストのコードから入出力できるようにする
$stdin, p_in = IO.pipe
p_out, $stdout = IO.pipe

# デバッグ用に、値をすぐ標準エラー出力へ出せるようにする
class Object
  def d
    $stderr.puts self
    self
  end
end

# スレッド内で対話テストのコードを動かす（ n はメイン側から渡す）
thread = Thread.new(n) do |n|
  # 隠しパラメータを設定
  s = Array.new(n + 1) { rand(2) }.join
  s[1] = "0"
  s[n] = "1"

  q = 20  # 質問可能な残り回数
  while (t, line = p_out.gets.d.split(nil, 2))
    case t
    when "!"
      # 解答を正誤判定して終了
      v = line.to_i
      ac = (1...n).cover?(v) && s[v] != s[v+1]
      if ac
        Thread.exit
      else
        raise "WA"
      end
    when "?"
      # 質問に応答
      raise "WA" if q == 0  # 質問回数超過
      q -= 1
      v = line.to_i
      if (1..n).cover?(v)
        p_in.puts s[v].d
        p_in.flush
      else
        raise "WA"  # 不正な出力
      end
    else
      # 不正な出力
      raise "WA"
    end
  end

  # 解答せず終了
  raise "WA"
end

# メインのコード終了時に、スレッド側の終了を待って正誤判定までさせる
END { thread.join }
#--- ここまでテスト用 ---#

p0, p1 = 1, n

while p1 - p0 > 1
  pm = (p0 + p1) / 2
  puts "? #{pm}"
  $stdout.flush

  v = gets.to_i
  if v == 0
    p0 = pm
  else
    p1 = pm
  end
end

puts "! #{p0}"
$stdout.flush
```

これでコードテストのページでも簡単に試せるようになった。

![custom_test_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/127130/6d15098f-8185-ce1f-54aa-b642a2d72444.png)

## 雑多なメモ
上記のコード内にコメントしたものもあるが、試している際に引っかかった部分を以下に纏めておく。

- 対話テストのコードをスレッドで動かす場合、メインプログラム終了時にスレッド終了を待たないと最後まで対話されない
	- なので手っ取り早く [`END`](https://docs.ruby-lang.org/ja/3.3/doc/spec=2fcontrol.html#END) でスレッド待機処理を書いた
- [`Kernel.#gets`](https://docs.ruby-lang.org/ja/3.3/method/Kernel/m/gets.html) は `ARGF` から読み込むものであり、標準入力から読み込むとは限らない
	- コマンドライン引数に指定したファイルを次々読み込んでいく
	- `ARGV` が空のときだけ標準入力から読み込む
- スレッド内で値を返して終了する単純な方法は無い？
	- `Thread.exit` は値を返さない
	- `return` は LocalJumpError を起こす
		- ただのブロックでなく [lambda](https://docs.ruby-lang.org/ja/3.3/method/Kernel/m/lambda.html) を使えば回避できる<br>`Thread.new(&->{ return 42 }).value`
		- LocalJumpError を捕捉して値を取り出す手もある
- コードテストの環境では `tee -a /dev/stderr` が Permission denied になる
	- これが大丈夫なら `$stdin = p_in = IO.popen("tee -a /dev/stderr", "r+")` のようにして対話をデバッグ出力するつもりだった