UE の Slate とブラウザの canvas が同じ画面を描くかを、画素ではなく座標で突き合わせる
Checking that UE's Slate and a browser canvas draw the same screen, by coordinates instead of pixels
D15-3-T29-two-renderers-conformance・技術・日本語入力でプレイする仕組み
同じ画面の JSON を、UE の Slate とブラウザの canvas の二つが描きます。
二つが同じ所に同じ物を置いているかを、絵ではなく「矩形は 1 px 以内・文字は 2 px 以内」の数で突き合わせ、さらに仕様から書いた期待値と実装を一つずつ比べています。
『霧走町 流氷に眠る』の画面は、昨夜の T28 で書いた MomijiUI で描いています。
UE のゲームでも、ブラウザ版でも、タイトル・前口上・本編の捜査画面・メニューは同じ JSON です。
配置の式は C++(UE)と JavaScript(ブラウザ)の 2 か所にあります。片方だけ直せば、静かにずれます。
今日は、そのずれを捕まえる照合の作りを書きます。
この回の位置と、前後の回#
区切り: T25〜T29 画面と入力(5 回のうち 5 回目)
先に読むとよい回: T28(画面を JSON で書く)
次に読む回: T30(状態を持たないサーバ)
名前 — conformance testing#
- 正式名
- conformance testing(適合性試験)
- 近い名前
- differential testing(二つの実装の出力を比べる。T44 で書く)・test oracle(何が正しいかを決めるもの)
- よく知られた例
- web-platform-tests(WPT。ブラウザの共通の試験集)
初出:
conformance testing という語の初出は確かめられませんでした。
規格としてまとまった早い例は、OSI の ISO/IEC 9646(OSI conformance testing methodology and framework)です。試験集(Abstract Test Suite)を書く部分の Part 2 は、1994 年版が 1991 年の第 1 版を置き換えています(書誌で確かめました)。
WPT は、試験を reftest(2 つの頁の描画を比べる)と testharness.js(描画以外のふるまいを JavaScript で確かめる)の 2 種に分けています。
一般の解説 — conformance testing#
conformance testing(適合性試験)とは、実装が仕様どおりかを、仕様から作った試験集で確かめることです。
比べる相手は「仕様」で、別の実装ではありません。
通信プロトコルの世界では、仕様から実装に依らない試験(abstract test case)を書き、実装ごとにそれを流して合否を出します。ISO/IEC 9646 はその書き方と枠組みの規格です。
ブラウザでは WPT がこれに当たります。
reftest は、試したい機能を使った頁と、同じ見た目を別の簡単な方法で作った頁を描き、画面の絵を比べます。
testharness.js は、DOM の値やイベントの順番のような、絵ではないものを比べます。
よく知られた弱点が三つあります。
一つ目、試験集に無いものは見ない。全部 PASS でも、試験の外で実装はずれていられます。
二つ目、仕様が曖昧なら、試験も曖昧になる。曖昧な所は、どちらかの実装の振る舞いを「正」として書き写しがちです。
三つ目、絵の比較はもろい。字の縁のアンチエイリアス・画像の補間・色の混ぜ方は描画系ごとに違い、同じ規則で描いても絵は一致しません。
もう一つ、二つの実装を比べるだけ(differential testing)だと、両方が同じ誤りを持てば一致して PASS になります。正しさを決めるもの(test oracle)が別に要ります。
うちの保証: 画素は比べない#
MomijiUI の仕様(momiji-ui-framework.md §9)は、保証する物としない物を分けています。
保証する: 同じ木・同じ値・同じ字の計測から、同じ配置。矩形(板・枠・画像・線・楕円)の位置と大きさは 1 px 以内、文字は行ごとの位置が 2 px 以内。折り返しの行数、repeat と条件の結果、当たり、焦点の鍵と移動先、Modal の開閉、スクロール量、描かずに測った高さ、診断の結果。
保証しない: 画素の色。字の塗り、角や縁のアンチエイリアス、画像の補間、色の混ぜ方。
文字だけ 2 px なのは、送り幅の計測が Slate(FreeType)とブラウザ(Windows では DirectWrite と Skia)で 1〜2 px 違い、中央寄せ・右寄せの文字がその分ずれるからです。
規則は同じで、計測の値だけが違う、という扱いです。
書体も揃えています。両方とも同梱の BIZ UDP ゴシック(SIL OFL 1.1)の同じ TTF を読み、OS の書体には頼りません。ブラウザは書体を読み終えてから描いた結果しか、適合の根拠にしません。
フィクスチャ: 機能ごとに 1 枚#
試験集は Plugins/MomijiUI/Fixtures/ の JSON で、今日は 19 枚です。
1 枚が 1 つの機能(折り返し・GridLayout・条件・Modal の焦点・スクロール・文字欄の操作スコープ・文字の選択など)を受け持ちます。
1 枚は「画面の木」「差し込む値(bind)」「操作の手(scenarios)」「手ごとの期待値(expect)」でできています。
同じ親に Modal が 2 つ同時に出る場面の 1 枚を、そのまま写します(root の中身は一部だけ)。
{ "id": "modal-siblings",
"bind": { "vars": { "back": 1, "front": 1 } },
"root": { "type": "Panel", "w": 1280, "h": 720, "variant": "spacer", "children": [
{ "type": "Button", "id": "bg", "text": "bg", "action": "t:bg", "x": 40, "y": 40, "w": 120, "h": 40 },
{ "type": "Modal", "id": "back", "w": 400, "h": 200, "showIf": "back == 1", … },
{ "type": "Modal", "id": "front", "w": 300, "h": 120, "showIf": "front == 1", … } ] },
"scenarios": [ { "name": "front-wins", "steps": [
{ "hit": [640, 328], "expect": { "top": 2, "focus": "id:frontButton", "hit": "id:front" } },
{ "move": [0, 1], "expect": { "focus": "id:frontButton" } },
{ "click": [640, 328], "expect": { "events": 0 } },
{ "click": [640, 368], "expect": { "events": 1 } },
{ "bind": { "vars": { "front": 0 } }, "expect": { "top": 1, "focus": "id:backButton" } },
… ] } ] }
後に描いた(手前の)Modal だけが焦点と押下を受け、手前を閉じると後ろの Modal が最前面に戻る、という仕様の一文を、手と期待値に直したものです。
期待値の数字は、どちらかの実装を動かして写したのではなく、仕様の式から手で計算しています。たとえば手前の Modal は 300×120 で画面の中央、見出しは上端から 4 下に高さ 36、子は 48 下から並ぶので、y=328 は見出し(押しても何も起きない)、y=368 はボタンです。
照合の流れ#
入口は Scripts/momiji-ui-conformance.ps1 の 1 本です。
- UE の自動テスト MomijiUI.MomijiUIConformance を、エディタを -nullrhi(描画なし)で立てて回す。フィクスチャごとに Saved/MomijiUIConformance/<id>.ue.json を書く
- node の Tools/momiji-ui/conformance.mjs が小さな静的サーバを立て、ヘッドレスの Chrome に同じフィクスチャを配置させて <id>.web.json を書く
- -Browsers を付けると、Playwright の Chromium・Firefox・WebKit でも同じことをして <id>.<engine>.json を書く
- 突き合わせて、ブラウザごとに 1 行 MOMIJIUI PASS / FAIL を出す。どれか 1 つでも FAIL なら終了コード 1
ブラウザの描画器の源は Tools/momiji-ui/web/src/momiji-ui.mjs で、照合が読むのはそこから作る生成物です。生成物が源から作り直したものと 1 文字でも違えば、照合は始める前に FAIL にします。古い描画器と照合して PASS、を起こさないためです。
UE 側が書き出す描画命令は、Slate の描画命令の列を種類と矩形に畳んだものです(MomijiUISlateTests.cpp の PrimsOf から)。
case EKaedeDrawKind::Rect: Out.push_back({ {"kind","Rect"}, {"x",C.X}, {"y",C.Y}, {"w",C.W}, {"h",C.H} }); break;
case EKaedeDrawKind::Text: Out.push_back({ {"kind","Text"}, {"text",TCHAR_TO_UTF8(*C.Text)}, {"x",C.X}, {"y",C.Y} }); break;
…
default: break; // ClipPush/Pop・TextField(枠は Rect で出ている)は比べない
ブラウザ側は、配置と描画を分けてあり(layout → ops → paint)、配置の結果の ops を同じ形で書き出します。画面の外の間引きは止め(cull=false)、全部の命令を出します。
比べるもの 4 つ#
- 当たり。action が "t:" で始まる部品の鍵・矩形(1 px)・スクロールの鍵・焦点の群・上下左右の移動先。同じ鍵が 2 つあれば、出た順に #2 と番号を添えて対応づけます
- 描画命令の全部。種類が同じで、文字なら文字列が同じで位置が 2 px 以内、それ以外は位置と大きさが 1 px 以内のものを、1 対 1 で消し込みます。余りが 1 つでもあれば FAIL
- 操作シナリオ。フィクスチャの手を、本物の入力の経路で通します。UE は SKaedeUiCanvas に押下とキー入力を渡し、ブラウザは本物の <canvas> と <input> に DevTools からマウス・キー・IME を送ります。各手のあとに、焦点の鍵・最前面の群・スクロール量・押した鍵・高さ・編集中の欄・欄の中身・押された数・編集器の見える矩形・文字の選択を記録して比べます
- 診断。画面 JSON の検査(知らない type・値の型違い・id の重複など)で、同じ severity・code・path が出ること
2 の消し込みは、conformance.mjs ではこう書いてあります。
const TOL = 1;
const TEXT_TOL = 2;
const same = (u, w) => u.kind === w.kind && (u.kind === 'Text'
? (u.text === w.text && near(u.x, w.x, TEXT_TOL) && near(u.y, w.y, TEXT_TOL))
: (near(u.x, w.x) && near(u.y, w.y) && near(u.w, w.w) && near(u.h, w.h)));
for (const u of ue) {
const i = rest.findIndex((w) => same(u, w));
if (i < 0) { … msgs.push(`描画 UE だけ: ${describe(u)}`); continue; }
rest.splice(i, 1);
}
順番は比べません。描く順は描画系の都合で変わりうるからです。代わりに、余りを両側から数えます。
期待値は実装ごとに別々に#
二つの実装を比べるだけでは、両方が同じ誤りを持つと一致して PASS になります(一般の解説の最後の弱点)。
そこで 10 月 4 日から、手に書いた expect を、UE とブラウザのそれぞれと比べるようにしました。
function checkExpect(impl, fixture, rec) {
…
for (const k of ['focus', 'top', 'hit', 'under', 'active']) if (k in e && (e[k] ?? '') !== (got[k] ?? '')) bad(k, e[k], got[k]);
if ('events' in e && (e.events || 0) !== (got.events || 0)) bad('events', e.events, got.events);
if ('h' in e && !near(e.h, got.h)) bad('h', e.h, got.h);
…
}
出力は「期待(firefox) シナリオ … 手3: active 期待="" 実="field"」のように、どの実装のどの手が仕様から外れたかを 1 行で言います。
期待値の検査が本当に効くかは、直す前の古い描画器を --canvas で渡して、FAIL になることで確かめます。
照合の流れ全体から言うと、UE とブラウザの一致が differential testing、expect が test oracle、両方を通って初めて PASS です。
今日の数字 — conformance testing#
今日は UE を起動していません(UE 込みの照合はエディタを立てるため)。なので、UE との一致は今日の版では確かめていません。最後に UE 込みで回した記録は、10 月 15 日の作業記録の「MOMIJIUI PASS fixtures=19」です。
ブラウザ側だけを、道具とフィクスチャを作業用の場所に写して回しました。
- OS の Chrome(156.0.8078.49)。UE 側の記録が無いので 19 枚とも「UE 側が無い」の FAIL になりますが、ブラウザの期待値の不一致は 0 件でした(37 秒)
- その Chrome の記録を基準の置き場に写して、Playwright の 3 つを比べました
ブラウザ: chromium(153.0.8010.12)
MOMIJIUI PASS fixtures=19 hits=112 prims=302 steps=166 expects=96 engine=chromium
ブラウザ: firefox(155.0)
MOMIJIUI PASS fixtures=19 hits=112 prims=302 steps=166 expects=96 engine=firefox
ブラウザ: webkit(26.6)
MOMIJIUI PASS fixtures=19 hits=112 prims=302 steps=166 expects=96 engine=webkit
当たり 112・描画命令 302・操作の手 166・期待値 96 が、4 つのブラウザで同じでした。1 回 37 秒前後です。
変換中の Enter(IME)だけは、Chromium は本物の composition を起こせ、Firefox と WebKit は口が無いので合成イベントで送っています。どちらで送ったかは記録の各手に残します。
名前どおりでない所 — conformance testing#
仕様が先にあり、仕様から書いた試験集で実装を確かめる、という形は conformance testing そのものです。
ただし、名前どおりでない所が三つあります。
一つ目、試験集を書いたのも、実装を書いたのも同じ側です。外の第三者が作った試験集ではありません。
二つ目、仕様は UE を「参照実装」と呼び、ブラウザはそれに合わせると決めています。UE との一致を見る部分は、仕様に対する適合というより、参照実装との differential testing です。期待値(96 か所)の部分だけが、仕様に対する適合です。
三つ目、絵は比べません。-Shots で UE とブラウザの絵を並べた頁は作れますが、人が見るためのもので、合否に入れていません。WPT の reftest のような画素の比較は採っていません。
PASS の意味は、当たり・描画命令・操作シナリオ・診断の 4 つと期待値だけです。画素も、照合していないブラウザも含みません。
弱点と、採らなかった案 — conformance testing#
- フィクスチャに無い組み合わせは見ていません。19 枚は機能ごとの 1 枚で、霧走町の実際の画面そのものではありません。実際の画面は、別の道具(adv-screen-compare)で UE とブラウザの絵を並べて人が見ています。
- 見た目の描き方(グラデーション・枠の色と太さ・影など)は UE だけにあり、ブラウザは診断の鍵の表だけ持っています。ここは照合の外です。
- 製品版の Safari、Mac と Linux のブラウザは確かめていません。Playwright の WebKit は Safari そのものではないので、「WebKit で PASS」と「Safari で確認」は別に数えます。折り返しの境目ちょうどの文では、計測の違いで行数が 1 変わりえます。そのときは数値を実装に合わせて書き換えず、文か折り返しの幅を直す決まりです。
- UE 側を回すにはエディタのバイナリが要り、エディタは同時に 1 本と決めているので、照合はいつでも気軽に回せるものではありません。
- 描画命令の消し込みは先に見つかった相手を取る貪欲で、1 px 以内に同じ種類の命令が並ぶと、別の物どうしが対応づく余地があります。いまのフィクスチャでは困っていません。
採らなかった案:
- 画素の差を比べる。アンチエイリアスと画像の補間で必ず違い、許す幅を決めると「どこまで違えば壊れたか」が言えなくなる
- ブラウザの描画を UE に合わせて画像で出す(UE が描いた絵を配る)。文字欄の IME と、画面の大きさへの追従が失われる
- 配置の C++ を WebAssembly にして、式を 1 か所にする。T28 で書いたとおり、字の計測はブラウザでするので、計測を呼び戻す橋が要る。いまは式を 2 か所に置き、この照合で揃えています
新しい描画系(たとえば別のエンジン)で MomijiUI を実装するときも、この照合を通れば「MomijiUI の実装」と名乗れる、と仕様に書いています。フィクスチャを読み、当たり・描画命令・シナリオ・診断を同じ形の JSON で書き出す口を作れば、同じ突き合わせに乗ります。
検索語#
conformance testing ISO/IEC 9646
web-platform-tests reftest testharness.js
differential testing test oracle problem
MomijiUI は MIT で GitHub に公開しています。画面を JSON で書いて UE とブラウザで動かしたい人は、プロフィールのリンクから辿ってみてください。