vm2gol v2 (42) ライフゲームのテスト



これまでずっと、テスト書かなきゃなーとは思っていたのですが、書かないままズルズルとここまで来てしまいました。 いいかげん書かないと。


これまで(第38〜40回あたり)はライフゲームを実行して動きを目で見て壊れてなさそうだと判断していましたが、その作業を自動化しましょう。


テスト実行の流れはこんな感じでどうでしょうか:

  • (1) gol.vg.txt から出発し、パース・コード生成・アセンブルを実行して実行ファイルを作る
  • (2) VM で実行ファイルを実行する
  • (3) 適当なところで実行を止める
  • (4) VM の内部を見て、期待する状態になっているか検証する

(1) (2) はこれまでやってきた通りで、 run.sh が行っていることとほぼ同じです。(とりあえずは)テストコードから同じように実行してやればよいでしょう。

(3) 現状では gol.vg.txt は Ctrl-C で止めるまで動き続けるようになっています。 ここは(可能ならテストのときだけ)適当な数の世代が経過したら終了するように変更します。

(4) 検証をどうするか少し考えましたが、 VRAM のメイン領域だけをダンプして、期待する状態と比較するようにします。


では修正していきます。

まずは適当な世代が経過したら終了するようにしてみます。

適当に動かして調べてみたところ、20世代分の処理が完了した時点でグライダーが1週して元の位置に戻ると分かりました。 *1 ひとまずそこで終わるようにしてみます。

--- a/gol.vg.txt
+++ b/gol.vg.txt
@@ -201,8 +201,11 @@ func main() {
   call vram_set(w, 1, 2, 1);
   call vram_set(w, 2, 2, 1);
 
-  while (0 == 0) {
+  var gen_limit = 21;
+  var gen = 1;
+  while (gen != gen_limit) {
     call make_next_gen(w, h);
     call replace_with_buf();
+    set gen = gen + 1;
   }
 }

./run.sh gol.vg.txt を実行すると、期待通りに元の位置に戻った状態で終了することが確認できました。 よしよし。


今の状態でテストコードから vgvm.rb を require すると、 require した瞬間に実行が始まってしまいます。 それでは困りますから、エントリポイントの部分を if $0 == __FILE__ ... end で囲みます。

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -481,6 +481,7 @@ class Vm
   end
 end
 
+if $0 == __FILE__
 exe_file = ARGV[0]
 
 stack_size = 50
@@ -489,3 +490,4 @@ vm = Vm.new(mem, stack_size)
 vm.load_program(exe_file)
 
 vm.start
+end

※インデントは後でまとめて修正しました(以下も同様)。


テストを実行するときはダンプ表示や $stdin.gets によるユーザ入力待ちは不要です。 環境変数 TEST の有無を見て、テストのときはダンプ表示などを行わないようにしました。

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -139,6 +139,10 @@ class Vm
     @step = 0
   end
 
+  def test?
+    ENV.key?("TEST")
+  end
+
   def set_sp(addr)
     raise "Stack overflow" if addr < 0
     @sp = addr
@@ -149,9 +153,11 @@ class Vm
   end
 
   def start
+    unless test?
     dump_v2() # 初期状態
     puts "Press enter key to start"
     $stdin.gets
+    end
 
     loop do
       @step += 1
@@ -236,6 +242,7 @@ class Vm
         raise "Unknown operator (#{op})"
       end
 
+      unless test?
       if ENV.key?("STEP")
         dump_v2()
         $stdin.gets
@@ -245,6 +252,7 @@ class Vm
       end
 
       # sleep 0.01
+      end
     end
   end

ためしに時間を測ってみると、20世代の処理が約 0.75秒で終わりました。 やはりダンプ表示をやめると速くなりますね。 ちなみに第38回で10ステップごとに1回ダンプ表示を行うようにした 段階では約20秒かかっていました。

終了時のステップ数を見てみると 423,283 でした。 1命令の実行あたりにかかっている時間は平均して約 1.8 マイクロ秒ということになります。


20世代の処理が終わって while ループを抜けると、 main 関数自体からも抜けることになります。 その次に何が起こるかというと、exit 命令の実行です。

現状では exit 命令が来たときに Kernel#exit するようになっていますが、 ここで Ruby のプログラム全体が終了してしまうと 検証がやりにくいので、単に return して Vm#start から戻るだけにします。

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -169,8 +169,7 @@ class Vm
 
       case op
       when "exit"
-        $stderr.puts "exit"
-        exit
+        return
       when "set_reg_a"
         val = @mem.main[@pc + 1]
         set_reg_a(val)
@@ -499,4 +498,5 @@ vm.load_program(exe_file)
 
 vm.start
 vm.dump_v2()
+$stderr.puts "exit"
 end

VRAM のメイン領域だけをダンプする dump_vram_main()Vm と Memory に追加。 Memory#dump_vram をコピペして、VRAM 全体ではなくメイン領域だけをダンプするようにしました。

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -115,6 +115,15 @@ class Memory
       format_cols(main[li]) + " " + format_cols(buf[li])
     }.join("\n")
   end
+
+  def dump_vram_main
+    rows = @vram.each_slice(5).to_a
+    main = rows[0..4]
+
+    (0..4).map {|li|
+      format_cols(main[li])
+    }.join("\n")
+  end
 end
 
 class Vm
@@ -324,6 +333,10 @@ class Vm
     EOB
   end
 
+  def dump_vram_main
+    @mem.dump_vram_main()
+  end
+
   def add_ab
     @reg_a = @reg_a + @reg_b
   end

これで準備ができました。 テストコードを書きます。

20世代で1週するケースをテストしておけば、トーラスの継ぎ目の処理がうまく動かなくなった場合でも気づけますし、最低限のテストとしては悪くないんじゃないでしょうか。

# coding: utf-8
require "minitest/autorun"
require_relative "../vgvm"

class GolTest < Minitest::Test
  PROJECT_DIR = File.join(__dir__, "../")
  TMP_DIR = File.join(PROJECT_DIR, "tmp")

  VG_FILE = File.join(PROJECT_DIR, "gol.vg.txt")
  VGT_FILE = File.join(TMP_DIR, "gol.vgt.json")
  ASM_FILE = File.join(TMP_DIR, "gol.vga.txt")
  EXE_FILE = File.join(TMP_DIR, "gol.vge.yaml")

  def setup
    ENV["TEST"] = ""

    stack_size = 50
    mem = Memory.new(stack_size)
    @vm = Vm.new(mem, stack_size)
  end

  def test_20generations
    system %Q{ ruby #{PROJECT_DIR}/vgparser.rb #{VG_FILE}  > #{VGT_FILE} }
    system %Q{ ruby #{PROJECT_DIR}/vgcg.rb     #{VGT_FILE} > #{ASM_FILE} }
    system %Q{ ruby #{PROJECT_DIR}/vgasm.rb    #{ASM_FILE} > #{EXE_FILE} }

    @vm.load_program(EXE_FILE)
    @vm.start()

    assert_equal(
      [
        ".@...",
        "..@..",
        "@@@..",
        ".....",
        ".....",
      ].join("\n"),
      @vm.dump_vram_main()
    )
  end
end

ruby test/gol_test.rb でテストが実行できます。


せっかくなので最初の1世代だけ実行した時点のテストケースも追加します。

最初の1世代が終わった時点ではこうなっていてほしい。

.....
@.@..
.@@..
.@...
.....

ちょっと強引ですが、gol.vg.txt のコードの var gen_limit = 21; の部分を文字列置換で直接書き換えるようにしました。

  def test_first_generation
    # 1世代で終了するように書き換える
    vg_file_replaced = File.join(TMP_DIR, "gol_replaced.vg.txt")
    src = File.read(VG_FILE)
    open(vg_file_replaced, "w") {|f|
      f.print src.sub("var gen_limit = 21;", "var gen_limit = 2;")
    }

    system %Q{ ruby #{PROJECT_DIR}/vgparser.rb #{vg_file_replaced} > #{VGT_FILE} }
    system %Q{ ruby #{PROJECT_DIR}/vgcg.rb     #{VGT_FILE} > #{ASM_FILE} }
    system %Q{ ruby #{PROJECT_DIR}/vgasm.rb    #{ASM_FILE} > #{EXE_FILE} }

    @vm.load_program(EXE_FILE)
    @vm.start()

    assert_equal(
      [
        ".....",
        "@.@..",
        ".@@..",
        ".@...",
        ".....",
      ].join("\n"),
      @vm.dump_vram_main()
    )
  end

これでまた安心感が増しました。


テスト実行でない普通の実行の場合は、やはり今までと同じようにずっと動き続けてほしいので、gen_limit = 0 としておきました。 併せてテストコードの方も修正します。

--- a/gol.vg.txt
+++ b/gol.vg.txt
@@ -201,7 +201,7 @@ func main() {
   call vram_set(w, 1, 2, 1);
   call vram_set(w, 2, 2, 1);
 
-  var gen_limit = 21;
+  var gen_limit = 0;
   var gen = 1;
   while (gen != gen_limit) {
     call make_next_gen(w, h);
--- a/test/gol_test.rb
+++ b/test/gol_test.rb
@@ -20,7 +20,14 @@ class GolTest < Minitest::Test
   end
 
   def test_20generations
-    system %Q{ ruby #{PROJECT_DIR}/vgparser.rb #{VG_FILE}  > #{VGT_FILE} }
+    # 1世代で終了するように書き換える
+    vg_file_replaced = File.join(TMP_DIR, "gol_replaced.vg.txt")
+    src = File.read(VG_FILE)
+    open(vg_file_replaced, "w") {|f|
+      f.print src.sub("var gen_limit = 0;", "var gen_limit = 21;")
+    }
+
+    system %Q{ ruby #{PROJECT_DIR}/vgparser.rb #{vg_file_replaced}  > #{VGT_FILE} }
     system %Q{ ruby #{PROJECT_DIR}/vgcg.rb     #{VGT_FILE} > #{ASM_FILE} }
     system %Q{ ruby #{PROJECT_DIR}/vgasm.rb    #{ASM_FILE} > #{EXE_FILE} }
 
@@ -44,7 +51,7 @@ class GolTest < Minitest::Test
     vg_file_replaced = File.join(TMP_DIR, "gol_replaced.vg.txt")
     src = File.read(VG_FILE)
     open(vg_file_replaced, "w") {|f|
-      f.print src.sub("var gen_limit = 21;", "var gen_limit = 2;")
+      f.print src.sub("var gen_limit = 0;", "var gen_limit = 2;")
     }
 
     system %Q{ ruby #{PROJECT_DIR}/vgparser.rb #{vg_file_replaced} > #{VGT_FILE} }

あとは、 gen_limit の書き換え処理やコンパイル部分の処理が重複していたので適当にメソッド抽出しておきました(diff は省略)。


あー、あと、よく考えたら dump_vram_main() はテストのときしか使わないので、 vgvm.rb に置いておく必要はないですね。 テストコードの方に移動させました(diff は省略)。


というわけで、めでたくライフゲームのふるまいを自動でテストできるようになりました 🎉

これでこの先安心してリファクタリングを進めることができます。



*1:この確認のため、以前適当に書いた gol.rb を修正して gol.vg.txt に近い形にしました。