業務の引き継ぎを題材にしたブラウザゲーム「引き継ぎラボ」を作りました。背景・立ち絵・台詞の窓・選択肢・手帳が1画面に重なる、ノベルゲーム(ADV)風の画面です。
この手の画面は、画面サイズが変わると静かに壊れます。選択肢が別の窓の下に潜る、台詞が窓の外で切れる、ページが少しだけスクロールする。どれもエラーは出ません。スクリーンショットを目で見ても、1〜2枚では見落とします。
そこで、Playwrightで「押せるか・切れていないか・はみ出していないか」を11の画面サイズ × 9場面で機械的に検査しました。その検査の書き方と、検査があって初めて見つかった罠をまとめます。
前提
- TypeScript + Vite の静的サイト(サーバー通信なし)
- 単体テスト:Vitest 71件(判定ロジックはDOMに触らない純粋関数に分離)
- 画面のテスト:Playwright 1.56 + Chromium、39件
- 設計方針:ページ全体はスクロールさせない。1画面に収め、内容が溢れたときだけ特定の窓の中をスクロールさせる
検査1:ボタンの中心を、本当にそのボタンが受け取るか
重なりの多い画面で一番起きるのは、「表示はされているのに、上に別の要素がかぶっていて押せない」状態です。toBeVisible() は通ってしまいます。
document.elementFromPoint() で、ボタンの中心座標でポインターイベントを受け取る要素を取り、それがボタン自身(か、その子孫)かを確かめます。pointer-events: none の飾りは飛ばされ、画面外の座標では null が返ります。
for (const button of document.querySelectorAll<HTMLButtonElement>(".try-controls button, .stock button, .write button")) {
if (!visible(button) || button.closest(".is-off")) continue;
const rect = button.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
const hit = document.elementFromPoint(x, y);
if (!hit || !button.contains(hit)) {
faults.push(`押せない: ${button.textContent?.trim().slice(0, 24)} → ${hit?.className ?? "画面外"}`);
}
}
失敗メッセージにかぶっている側のクラス名を出すのがポイントです。「選択肢が手帳の下に潜っている」と、名前ですぐ分かります。
Playwrightの click() も、クリックの前に「その位置で他の要素にイベントを奪われないか」を確認します(表示・安定・有効の確認と合わせた actionability check)。ただし、確認するのは押そうとした1つだけで、失敗の出方はタイムアウトです。この検査なら、その場面にある全ボタンの状態を1回で一覧にできます。中心点だけの確認なので、端が隠れているかまでは見ていません。
検査2:overflow: hidden の中で切れていないか
2つ目は「切れ」です。窓に overflow: hidden を付けていると、中身が溢れても画面上は見えなくなるだけで、エラーも警告も出ません。
子孫まで全部たどり、overflow が hidden か clip の要素で scrollHeight > clientHeight になっているものを探します。
for (const root of document.querySelectorAll<HTMLElement>(".order, .stock, .try-controls, .pane-work .stage")) {
for (const node of [root, ...root.querySelectorAll<HTMLElement>("*")]) {
if (!visible(node)) continue;
const style = getComputedStyle(node);
if (["hidden", "clip"].includes(style.overflowX) && node.scrollWidth > node.clientWidth + 1 ||
["hidden", "clip"].includes(style.overflowY) && node.scrollHeight > node.clientHeight + 1) {
faults.push(`切れ: ${node.className} ${node.scrollWidth}x${node.scrollHeight}/${node.clientWidth}x${node.clientHeight}`);
}
}
}
親だけを見ても検出できません。 最初は、スクロールできる窓(.pane-body)の scrollHeight だけを見ていました。ところが、窓の中の場面に overflow: hidden と min-height: 0 が付いていると、場面は窓の高さに合わせて縮みます。はみ出た台詞は場面の中で切られるので、親の scrollHeight には現れません。3つ目の選択肢が見えないのに、テストは通っていました。
+1 は、小数点のピクセルの丸めで誤検知しないための余白です。なお、これは「hidden/clip の要素で溢れが起きているか」を見る検査で、あらゆる切れを証明するものではありません。この画面で実際に起きた、内側の窓での切れを捕まえるためのものです。
検査3:ページがスクロールしないか
「ページ全体はスクロールさせない」の検査です。scrollY が0かを見るだけでは、まだスクロールしていないだけかもしれません。実際に端までスクロールさせて、動かないことを確かめます。
scrollTo(10000, 10000);
if (scrollY !== 0 || scrollX !== 0) {
const protruding = [...document.querySelectorAll<HTMLElement>("body *")]
.filter((n) => n.getBoundingClientRect().bottom > innerHeight + 1)
.slice(0, 8)
.map((n) => `${n.className}:${Math.round(n.getBoundingClientRect().bottom)}`);
faults.push(`ページがスクロールする x=${scrollX} y=${scrollY}: ${protruding.join(", ")}`);
}
scrollTo(0, 0);
失敗したときは、画面の下端より下に出ている要素を最大8個並べます。意図して画面外に置いた要素も混ざるので、あくまで原因の候補ですが、たいていはこれで見当がつきます。この画面では scroll-behavior: smooth を使っていないので、scrollTo() の直後に位置を読んでいます。
検査4:台詞が画面の外に出ていないか
台詞の窓・吹き出し・地の文は、getBoundingClientRect() の上下が画面の中にあるかを見ます。切れの検査(検査2)とは別に要ります。絶対配置の要素は通常の配置の流れから外れるので、置き方によっては、検査2で見ている窓の溢れとして現れないまま画面外へ出るからです。
11の画面サイズ × 9場面で回す
検査は1つの関数(expectAdvLayout)にまとめ、画面サイズごとに1テストを生成します。
const LAYOUT_SIZES = [
[1920, 960], [1600, 860], [1440, 800], [1366, 768], [1280, 900],
[1100, 800], [960, 1000], [390, 844], [360, 740], [1536, 754], [1280, 640],
] as const;
for (const [width, height] of LAYOUT_SIZES) {
test(`ADV配置を機械検査する(${width}×${height})`, async ({ page }) => {
await page.setViewportSize({ width, height });
// 注文を読む → 在庫を選ぶ → 電話の返事を読む → 電話のあと → 判断 → 聞き取って書く → 連絡 → 記録 → 一日の終わり
// の各場面で expectAdvLayout(page, `${width}×${height} ${scene}`) を呼ぶ
});
}
サイズの選び方は、次のとおりです。
- ノートPCで多い 1366×768 と、手元のノートPC(表示倍率を上げた状態)で実際に測ったビューポート 1536×754
- ブラウザのタブやブックマークバーで高さが削られた 1280×640
- スマホ幅 390×844、360×740(ビューポートの大きさだけで、タッチ操作までは再現していません)
- レイアウトが切り替わる境目(1000px)の前後 960、1100
横幅より、高さの短いサイズで壊れました。 1920×1080だけで確認していると、ほぼ見落とします。
場面ごとにスクリーンショットも保存します。機械検査が通ったあと、見た目の違和感だけを目で見ます。
検査があって見つかった罠
固定表示のタイトルカードが、半分だけ画面の左に出ていた
「DAY 1/3」のタイトルカードは、position: fixed; inset: 0 で全画面を覆うはずでした。ところが、以前の別のルールが残っていました。width: 520px と transform: translateX(-50%) です。後から足したルールがこの2つを打ち消していなかったので、カードは x=-260、幅520px で表示されていました。
スクリーンショットの確認はいつも[始める]を押したあとの場面だったので、見落としていました。今は、押す前の状態を、幅と位置の数値で固定しています。
const box = await page.locator(".day-title-card").boundingBox();
expect(Math.round(box!.x)).toBe(0);
expect(Math.round(box!.width)).toBe(width);
expect(Math.round(box!.height)).toBe(height);
全画面の要素は「表示されているか」ではなく、幅と高さが画面と一致するかで見ます。
CSSの content の文字が、ボタンの名前に混ざる
選択肢に「1」「2」…のキー表示を付けるのに、最初は ::before { content: "1" } を使いました。すると、在庫の数字ボタン「5」のアクセシブルネームにキー表示の文字が混ざり、getByRole("button", { name: "5", exact: true }) で見つからなくなりました。疑似要素の content の文字は、アクセシブルネームの計算に含まれます。
飾りは、aria-hidden="true" の要素にするか、文字を使わずに図形(border で作る三角など)で描きます。
<button><span class="keycap" aria-hidden="true">1</span>5</button>
ホバーでボタン自体を動かすと、クリック時にページがスクロールした
選択肢をホバー時に transform: translateX(4px) で右へずらす演出を付けたところ、Playwrightで選択肢をクリックしたあと、ページがスクロールした状態が検査3で見つかりました。仕組みまでは追い切っていませんが、ボタン自体を動かすのをやめ、ボタンの中の飾りだけを動かすようにしたら解消しました。
それ以来、押す対象の位置は動かさないことを設計のルールにしています。人のクリックにとっても、そのほうが安全です。
まとめ
| 検査 | 見るもの | 捕まえたもの |
|---|---|---|
| elementFromPoint | ボタン中心でイベントを受ける要素 | 別の窓の下に潜った選択肢 |
| 子孫の overflow |
hidden/clip で scroll > client
|
窓の中で切れた3つ目の選択肢 |
| 強制スクロール | 端までスクロールして動くか | 数px下にはみ出した要素 |
| 画面外 | 台詞の上下が画面内か | 絶対配置で画面外に出た吹き出し |
| 全画面の数値 | 幅・高さ・位置が画面と一致 | 半分だけ出ていたタイトルカード |
重なりの多い画面は、「見えている」ことと「使える」ことが一致しません。見た目の確認は人がやり、壊れ方の確認は機械に任せる。この分担にしてから、画面サイズを変えても安心して手を入れられるようになりました。
ゲーム本体は、ブラウザですぐ遊べます。
👉 引き継ぎラボ
業務アプリの画面でも、同じように崩れを機械で止める作りからご相談を承っています。
- ご依頼(クラウドワークス):https://crowdworks.jp/public/employees/6942778
