Akashic Engineで作成されたゲームのビジュアルテストをするためのツール【実装編】

この記事は、Akashic Engine Advent Calendar 2019の6日目の記事です。

# TL; DR
今回は、Akashic Engineで作成されたゲームのビジュアルテストをするためのツールをどのように実装しているかについて説明していきます。
ツールの概要・使い方については[こちら](https://qiita.com/dera_/items/5b6218ea5f6089338877)を参照してください。

# 各モジュールの大まかな役割
ビジュアルテストをするためのツールとして、[akashic-contents-reftest-runner(以下runnerと略します)](https://github.com/dera-/akashic-contents-reftest-runner)と[akashic-contents-reftest-helper(以下helperと略します)](https://github.com/dera-/akashic-contents-reftest-helper)を作成しましたが、これらの大まかな役割は以下の通りです。

- runner
  - サーバー(ゲーム)の起動
  - ゲームへのアクセス
  - スクリーンショット画像の保存
  - テストの実行
- helper
  - ゲームの操作内容(シナリオ)の取得
  - シナリオ自動実行
    - ちなみに現状helperが対応している操作内容は以下の通りです
      - クリック操作
      - スクリーンショットデータ取得
      - どこで終了するかのタイミング決定

# runnerの実装
ここではrunnerの実装について、コードを用いて説明していきます。
runnerはtypescriptで書かれているため、typescriptでサンプルコードを書いていきます。

## サーバーの起動
まず、runner上でゲームを実行するためのサーバーを起動する必要があります。
runnerでは、akashic-cliのserve機能を利用してサーバーを起動しています。serve機能については以下のリンクを参照してください。

- [npmページ](https://www.npmjs.com/package/@akashic/akashic-cli-serve)
- [serve機能について説明されているページ](https://qiita.com/xnv/items/6c328de41ce6939d34d0#akashic-serve-%E3%81%AE%E4%BE%BF%E5%88%A9%E6%A9%9F%E8%83%BD%E3%82%82%E3%82%8D%E3%82%82%E3%82%8D)

要するに、serve機能を用いて指定したゲームを起動することができます(ただ実際にゲームをプレーするためには起動時に表示されるURLにアクセスする必要があります。)
serve機能を利用してサーバーを起動する処理は以下の通りです。

```typescript
import { spawn } from "child_process";
import * as path from "path";
import * as getPort from "get-port";

const port = await getPort();
const akashicCliServePath = path.join(__dirname, "..", "node_modules", ".bin", "akashic-cli-serve");
let childProcess = spawn(
	akashicCliServePath,
	["-p", port.toString()],
	{cwd: "ここにコンテンツのgame.jsonがあるパスを指定"}
);
```

あまり特筆すべき箇所はない処理ですが、[get-port](https://www.npmjs.com/package/get-port)で空いているポートを取得して、OSのコマンドで直接serveを実行しています(nodeをインストールしていれば恐らくはWindowsでもMacでも動くかと思います)。これで`cwd`で指定したゲームが起動することになります。

## ゲームへのアクセス
次に、上記で起動したゲームのURLにアクセスしてゲームをプレーする必要があります。
ゲームへのアクセスのために[peppeteer](https://www.npmjs.com/package/puppeteer)を利用します。
puppeteerはスクレイピング等ブラウザ上の処理を自動で実行してくれるツールで、ヘッドレスChrome上で動くようになっています。
なので、runnerを使うためにはお使いのPCにchromeを入れておく必要があります(恐らくこの記事を閲覧されている方のPCには入っていると思われるので説明不要かなと勝手に思ってました)

puppeteerを用いてゲームを起動する処理は以下の通りです。

```typescript
import * as puppeteer from "puppeteer";

const WAITING_SERVE_TIME = 5000; // 単位はミリ秒

const browser = await puppeteer.launch({
	headless: true,
	executablePath: process.env.CHROME_BIN || null,
	args: ["--no-sandbox", "--headless", "--disable-gpu", "--disable-dev-shm-usage"]
});
const page = await browser.newPage();
await page.waitFor(WAITING_SERVE_TIME); // serveが起動するのを待つために少し長めに待つ
await page.goto("serve機能で起動したゲームのURL");
```

これで、ゲームへのアクセスができますが、惜しむべきはserveによってゲームがいつ起動するか分からないためやや長めに待つというフワッとした処理をしてしまっているところでしょうか。。。

## スクリーンショット画像の保存
ゲームにアクセス後、ゲームを実際にプレーしてその内容をスクリーンショットに保存します。
ゲームのプレーについてはhelperで行う内容ですのでそちらで後述します。
ここでrunnerが行うのがスクリーンショット画像の保存ですので、そちらについて説明していきます。その処理内容が以下の通りです。

```typescript
import * as fs from "fs";
import * as path from "path";

page.on("console", async (msg) => {
	const data = await Promise.all(msg.args().map((a) => a.jsonValue()));
	if (data.length === 3 && data[0] === "akashic-contents-reftest:image") {
		// スクリーンショットがbase64のバイナリとしてconsole上に流れるので、デコードして保存する
		const decode = Buffer.from(data[2], "base64");
		fs.writeFileSync(path.join(dir, data[1]), decode);
	}
});
```

ここでもpuppeteerを利用しています。
ここではゲーム側からの`console.log`を監視して特定のメッセージを受け取ったら、スクリーンショット画像がbase64にエンコードされたものをデコードして画像として保存することを行っています。
スクリーンショット画像をbase64にデコードしてコンソールに流す処理自体はhelperで行っていますので、詳細はhelperのセクションで後述します。

## テストの実行
次に、テストとして上記で保存したスクリーンショット画像と期待する表示内容との比較検証を行います。
以下の`assertScreenshot`関数を使って比較検証を行います。

```typescript
import * as fs from "fs";
import * as assert from "assert";
import * as pngjs from "pngjs";

const pixelmatch = require("pixelmatch");

const assertScreenshot = (expectedPath: string, targetPath: string, diffPath: string) => {
	// 予め用意しているスクリーンショットと画像を比較する時diffをどこまで許容するかの値
	// このdiffの値域は0～1で、2つの画像でdiffがある領域の割合を示していて、閾値は5%に設定している
	const screenshotDiffThreshold = 0.05;
	const expected = pngjs.PNG.sync.read(fs.readFileSync(expectedPath));
	const actual = pngjs.PNG.sync.read(fs.readFileSync(targetPath));
	const {width, height} = expected;
	const diff = new pngjs.PNG({width, height});
	// thresholdはピクセルごとの差異の閾値を表している
	const value = pixelmatch(expected.data, actual.data, diff.data, width, height, {threshold: 0.1});
	console.log(`diff: ${100 * value / (width * height)}%`);
	fs.writeFileSync(diffPath, pngjs.PNG.sync.write(diff));
	assert(value <= screenshotDiffThreshold * width * height);
};
```
この関数では、[pixelmatch](https://www.npmjs.com/package/pixelmatch)を用いて画像の比較検証を行います。
pixelmatchは、1ピクセルずつ差分があるかの検証を行って差分のあったピクセル数を返してくれるので、その値を画像の画素数で割ってdiffの割合を出力してます。assert関数を使っているため、diffの割合が閾値を超えてしまったら即テストが落ちてしまう仕組みになってしまっているのが難点ですが。。
また、pixelmatchはdiff画像も作成してくれるので、その画像も出力するようにしています。

# helperの実装
ここではhelperの実装について、コードを用いて説明していきます。
helperもtypescriptで書かれているため、typescriptでサンプルコードを書いていきます。

## ゲームの操作内容(シナリオ)の取得
`sandbox.config.js`でシナリオを記述するという話を前回の記事で書いたと思いますが、その取得をhelperで行っています。
その処理の内容は以下の通りです。

```typescript
interface Command {
	name: string;
	options: any;
}

interface Scenario {
	age: number;
	commands: Command[];
}

let scenarioTable: Scenario[] = [];

scene.message.add((msg) => {
	if (msg.data && msg.data.type === "scenario" && msg.data.scenarioTable) {
		scenarioTable = msg.data.scenarioTable;
	}
});
```

Akashic Engineの`Scene#message`のイベントハンドラを利用して、セッションパラメータとして登録されたscenarioTableを受け取る処理を行っています。

## シナリオ自動実行
上の方でも記載しましたが、helper側でシナリオに書かれた操作を実際に行います。
その操作をするための関数(`runScenario`)があるのですが、`runScenario`の説明の前にいつ関数を呼び出すのかについて説明します。
関数の呼び出しの処理内容は以下の通りです。

```typescript
const runScenarioEvent = () => {
	const target = scenarioTable.filter(scenario => {
		return scenario.age === g.game.age;
	});
	if (target.length > 0) {
		// ここでシナリオに書かれた操作を実際に行う関数を呼び出す
		runScenario(g.game.scene(), target[0].commands);
	}
};

// game単位でのイベント登録ができないので、sceneが変わるたびにイベントを登録する必要がある
g.game._sceneChanged.add((s) => {
	if (s && s.update && !s.update.contains(runScenarioEvent)) {
		s.update.add(runScenarioEvent);
	}
});
```

コメントにも書いてますが、シーンが変わるごとに`Scene#update`のハンドラに関数を呼び出すための関数(`runScenarioEvent`)を登録しています。
そして、`runScenarioEvent`でフレーム数を毎回計測して、指定したフレーム数になったら`runScenario`を呼ぶ処理を行っています。

以下に、実際に操作を行う関数`runScenario`の内容を記載します。

```typescript
const runScenario = (scene: g.Scene, commands: Command[]): void => {
	for (let i = 0; i < commands.length; i++) {
		const com = commands[i];
		switch (com.name) {
		case "click":
			const point = { x: com.options.x, y: com.options.y };
			const pointSource = scene.findPointSourceByPoint(point);
			const targetPoint = pointSource.point || point;
			g.game.raiseEvent(new g.PointDownEvent(0, pointSource.target, targetPoint));
			g.game.raiseEvent(new g.PointUpEvent(0, pointSource.target, targetPoint, { x: 0, y: 0 }, { x: 0, y: 0 }));
			break;
		case "screenshot":
			if (typeof window !== "undefined") {
				g.game.render(); // 描画がスキップされてしまうことがあるので、スクリーンショット取得前に現フレームでの描画を行う
				const canvasElements = window.document.getElementsByTagName("canvas");
				const imageUrl = canvasElements[0].toDataURL("image/png");
				const data = imageUrl.match(/^data:image\/png;base64,(.+)$/);
				if (data.length === 2) {
					console.log("akashic-contents-reftest:image", com.options.fileName, data[1]); // runner側にスクリーンショットの保存を要求
				}
			}
			break;
		case "finish":
			console.log("akashic-contents-reftest:finish"); // runner側にゲーム実行終了を通知
			break;
		}
	}
};
```

見ていただければ分かると思いますが、現状`runScenario`がサポートしている操作は画面をクリックする`click`、ゲーム画面のスクリーンショット画像を取得する`screenshot`、runner側にゲーム終了の通知を行う`finish`の3種類のみとなっています。`click`は実際にクリック動作をここで行うのですが、`screenshot`と`finish`はコンソールログに情報を流して実際の処理はrunner側に任せる仕組みになっています。

# まとめ
今回は、runnerとhelperの実装内容について説明しました。
両方ともですが、特にhelperはゲームの操作としてクリックしかできないなどできることが少なく発展途上な感じですので、今後改善していきたいと考えています。