在開始替我們的康威生命遊戲實作更多功能之前,要先將目前的Rust程式加入測試(Testing),除了可以用來驗證目前的程式正確性之外,還可以避免日後因修改程式而把原本可以正常工作的程式弄出問題了。另外,有些程式問題發生的原因很難確認,我們也需要在Rust程式中輸出Log到網頁瀏覽器的主控台(console)中,才能偵錯(Debugging)。



測試(Testing)

我們雖然成功在上一章的最後在網頁瀏覽器上顯示出康威生命遊戲的畫面,看到細胞的發瘋似地繁殖又滅亡,但我們根本無法確定這些細胞的變化是不是正確的。

為了驗證遊戲邏輯的正確性,我們會需要撰寫測試程式。我們其實可以使用Node.js引用WebAssembly程式來對其做整合測試,但筆者並不建議這麼做。因為Rust的WebAssembly開發環境已經有提供不錯的測試功能了,而且使用起來就像是原生的Rust測試程式的架構。

雖然本篇系列文章中還沒提到,但如果您有自行稍微看過wasm-pack-template模板的話,應該會注意到Cargo程式專案底下有個tests目錄,裡面有個web.rs檔案。這個檔案的內容如下:

#![cfg(target_arch = "wasm32")]

extern crate wasm_bindgen_test;
use wasm_bindgen_test::*;

wasm_bindgen_test_configure!(run_in_browser);

#[wasm_bindgen_test]
fn pass() {
    assert_eq!(1 + 1, 2);
}

注意到這個#[wasm_bindgen_test]屬性,這邊的程式結構非常像Rust原生的#[test]屬性,套用這個#[test]屬性的函數會變成測試函數。而#[wasm_bindgen_test]屬性也是類似的作用,套用#[wasm_bindgen_test]屬性的函數,也會變成測試函數,只不過這個測試函數的呼叫者不是Rust程式,而是系統環境中已安裝的網頁瀏覽器。

什麼意思呢?我們可以在終端機內執行以下指令:

wasm-pack test --chrome

rust-webassembly-testing-debugging

以上指令執行後,wasm-pack就會啟動一個HTTP伺服器,並針對Chrome瀏覽器提供程式的測試腳本。當我們用Chrome瀏覽器開啟http://127.0.0.1:8000/這個網址時,就會開始運行測試腳本了!

rust-webassembly-testing-debugging

除了能使用--chrome參數來指定用Chrome瀏覽器之外,wasm-pack也可以使用--firefox--safari參數來指定用FireFox瀏覽器或是Safari瀏覽器。瀏覽器參數可以同時使用多個。

不管用哪個網頁瀏覽器,都可以替wasm-pack test指令再加上--headless參數,使我們不必手動用網頁瀏覽器開啟網址,就能在終端機上直接看到測試結果。

rust-webassembly-testing-debugging

再來看到wasm_bindgen_test_configure巨集,它來自於wasm_bindgen_test這個crate,用法蠻奇怪,可以傳入多個用空格 分隔的參數,run_in_browser這個參數表示要求讓目前這個測試程式在網頁瀏覽器上運行。不錯,wasm-pack test指令除了用網頁瀏覽器來運行測試腳本之外,也可以用Node.js來測試,只要加上--node參數即可。

替康威生命遊戲撰寫WebAssembly測試

我們希望可以測試培養皿從目前這個狀態到下一個階段的狀態的變化,所以打算把那四條規則都測試過。在開始撰寫測試前,我們可以思考一下,看是不是需要替Universe結構體新增更多方法來完成測試。我們既然要測試培養皿的狀態變化,勢必得有個方法能夠手動設定哪些格子有活細胞,而且在計算完下一個狀態之後,也還要有個方法能夠取得或是判斷培養皿上的活細胞分佈狀態。

於是,我們需要先在src/lib.rs加入以下程式:

impl Universe {
    pub fn check_cells(&self, cells_indexes: &[usize]) -> bool {
        for (index, block) in self.blocks.iter().enumerate() {
            if cells_indexes.contains(&index) {
                if let &Block::NoCell = block {
                    return false;
                }
            } else {
                if let &Block::HasCell = block {
                    return false;
                }
            }
        }

        true
    }

    pub fn add_cells(&mut self, indexes: &[usize]) {
        for &index in indexes {
            self.blocks[index] = Block::HasCell;
        }
    }
}

注意這邊新增的Universe的方法,並不需要使用#[wasm_bindgen]屬性,因為我們不想把它們暴露給JavaScript程式。

然後就能開始撰寫測試的程式了,如下:

#![cfg(target_arch = "wasm32")]
extern crate wasm_game_of_life;

extern crate wasm_bindgen_test;

use wasm_bindgen_test::*;

use wasm_game_of_life::{Universe, Block};

wasm_bindgen_test_configure!(run_in_browser);

#[wasm_bindgen_test]
fn rule_1() {
    let mut universe = Universe::new(5, 5);

    universe.add_cells(&[12, 13]);

    universe.tick();

    assert!(universe.check_cells(&[]));
}

#[wasm_bindgen_test]
fn rule_2() {
    let mut universe = Universe::new(5, 5);

    universe.add_cells(&[8, 12, 16]);

    universe.tick();

    assert!(universe.check_cells(&[12]));
}


#[wasm_bindgen_test]
fn rule_3() {
    let mut universe = Universe::new(5, 5);

    universe.add_cells(&[7, 8, 12, 13, 16]);

    universe.tick();

    assert!(universe.check_cells(&[7, 8, 11, 13, 17]));
}

#[wasm_bindgen_test]
fn rule_4() {
    let mut universe = Universe::new(5, 5);

    universe.add_cells(&[6, 13, 16]);

    universe.tick();

    assert!(universe.check_cells(&[12]));
}

#[wasm_bindgen_test]
fn case_1() {
    let mut universe = Universe::new(5, 5);

    universe.add_cells(&[7, 12, 17]);

    universe.tick();

    assert!(universe.check_cells(&[11, 12, 13]));
}

測試的程式寫好之後就可以用剛才提到的wasm-pack test指令來執行。

rust-webassembly-testing-debugging

偵錯(Debugging)

用偵錯模式來建置專案

我們之前都是用wasm-pack build指令來建置專案,這個指令預設會使用release模式來編譯Rust程式。如果不要用release模式的話,可以改用wasm-pack build --debug指令來建置專案。

顯示panic訊息

當WebAssembly程式發生panic時,網頁瀏覽器或是Node.js的主控台不會出現panic訊息,這會讓偵錯變得有點麻煩。也許您可能已經有注意到,wasm-pack-template模板的src目錄中,除了有lib.rs檔案外,還有一個utils.rs檔案。在這個utils模組中,有提供一個set_panic_hook函數,只要讓WebAssembly程式在執行的過程中呼叫到這個函數,之後若WebAssembly程式發生panic,就會把panic訊息輸出到主控台。

我們可以在Universe結構體的new關聯函數中呼叫set_panic_hook函數。程式如下:

#[wasm_bindgen]
impl Universe {
    #[wasm_bindgen(constructor)]
    pub fn new(width: u32, height: u32) -> Universe {
        if cfg![debug_assertions] {
            utils::set_panic_hook();
        }

        ...
    }

    ...
}

如此一來,使用偵錯模式編譯出來的WebAssembly程式就會把panic訊息輸出到主控台了!

輸出訊息到主控台

我們無法直接用Rust的print!等巨集使WebAssembly程式輸出訊息到主控台。想要做到這件事的話,我們可以使用web-sys套件,它能用來綁定網頁瀏覽器的API(Web API),主控台的console也是可以綁定的。

想在Rust中呼叫到JavaScript的console,首先需要在Cargo.toml設定檔中,加入以下設定:

[dependencies.web-sys]
version = "0.3"
features = [
  "console",
]

接著在src/lib.rs中,加入以下程式碼:

extern crate web_sys;

macro_rules! log {
    ( $( $t:tt )* ) => {
        web_sys::console::log_1(&format!( $( $t )* ).into());
    }
}

web_sys這個crate提供的log_1函數即為JavaScript的console.log函數的只能傳一個參數的版本。web_sys也有提供能傳兩個參數的log_2函數,如果要傳三個參數就是用log_3,依此類推。不過由於這種API用起來有點麻煩,所以我們在這裡自訂了一個log巨集,就可以透過它來以類似print!巨集那樣的語法格式輸出訊息了!

例如:

log!("A = {}, B = {}", 5, 6);

以上程式會在主控台中輸出:

A = 5, B = 6

我們可以試著在Universe結構體的tick方法中加入以下程式:

#[wasm_bindgen]
impl Universe {
    pub fn tick(&mut self) {
        ...

        for row in 0..self.height {
            for column in 0..self.width {
                ...

                let has_cell = self.has_cell(index);

                let live_neighbor_cells_count = {
                    ...
                };

                if cfg![debug_assertions] {
                    log!(
                        "Is blocks[{row:5}, {column:5}] has a live cell? {has_cell:5}. And it has {live_neighbor_cells_count} live neighbors.",
                        row = row, column = column, has_cell = has_cell, live_neighbor_cells_count = live_neighbor_cells_count
                    );
                }

                ...
            }
        }

        ...
    }
}

如果您加上以上程式片段後就直接用偵錯模式重新建置專案,然後打開網頁瀏覽器的主控台看結果,您的網頁瀏覽器很可能會變得非常卡,甚至到凍結的地步,CPU使用率也會狂飆。這是因為tick方法是透過JavaScript的requestAnimationFrame函數來重複呼叫,它在一秒鐘內就有可能會被執行60次,且每執行一次就會輸出32 x 32 = 1024個Log(因為培養皿的WIDTHHEIGHT預設為32),所以會產生大量的主控台訊息,讓網頁瀏覽器來不及處理。

用JavaScript的debugger關鍵字建立偵錯點

為了解決上述網頁瀏覽器卡住的問題,在呼叫tick方法前,我們可以加入JavaScript提供的debugger關鍵字,用來建立一個偵錯點。

import('../../pkg/wasm_game_of_life')
    ...
    .then(module => {
        import('../../pkg/wasm_game_of_life_bg')
            .then(wasm => {
                ...

                let renderLoop = () => {
                    debugger;

                    universe.tick();

                    drawCells();

                    requestAnimationFrame(renderLoop);
                };

                ...
            });
    });

當網頁瀏覽器正在啟動「檢查元素」時,JavaScript程式若執行到debugger關鍵字,就會自動暫停,直到我們按下繼續執行(Resume)的按鈕,它才會再繼續執行接下來的程式。

rust-webassembly-testing-debugging

我們可以在JavaScript程式暫停的期間,慢慢研究主控台中已經被輸出的訊息,看看程式到底是在哪個環節出問題了。

rust-webassembly-testing-debugging

其它類型的訊息

我們都知道,console除了能用log函數來輸出一般訊息到主控台之外,還可以用info函數輸出資訊訊息、warn輸出警告訊息、error輸出錯誤訊息,這些函數都可以使用web_sys這個crate提供的log_1info_1warn_1error_1等函數來綁定。

當然,我們可以為它們都撰寫出如下的巨集:

macro_rules! log {
    ( $( $t:tt )* ) => {
        web_sys::console::log_1(&format!( $( $t )* ).into());
    }
}

macro_rules! info {
    ( $( $t:tt )* ) => {
        web_sys::console::info_1(&format!( $( $t )* ).into());
    }
}

macro_rules! warn {
    ( $( $t:tt )* ) => {
        web_sys::console::warn_1(&format!( $( $t )* ).into());
    }
}

macro_rules! error {
    ( $( $t:tt )* ) => {
        web_sys::console::error_1(&format!( $( $t )* ).into());
    }
}

總結

在這個章節中,我們學會了用Rust撰寫WebAssembly測試的方式,也知道要怎麼樣在網頁瀏覽器上對WebAssembly程式進行偵錯。另外,也大概會用web-sys套件來綁定網頁瀏覽器的API了。

下一個章節,我們要來替這個康威生命遊戲加上與玩家互動的功能。

下一章:讓康威生命遊戲能與玩家互動