0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ノベルゲーム風の画面を「押せない・切れる・はみ出す」から守るPlaywright検査──elementFromPointと11の画面サイズ

0
Posted at

ノベルゲーム風の画面を「押せない・切れる・はみ出す」から守るPlaywright検査──elementFromPointと11の画面サイズ

業務の引き継ぎを題材にしたブラウザゲーム「引き継ぎラボ」を作りました。背景・立ち絵・台詞の窓・選択肢・手帳が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下にはみ出した要素
画面外 台詞の上下が画面内か 絶対配置で画面外に出た吹き出し
全画面の数値 幅・高さ・位置が画面と一致 半分だけ出ていたタイトルカード

重なりの多い画面は、「見えている」ことと「使える」ことが一致しません。見た目の確認は人がやり、壊れ方の確認は機械に任せる。この分担にしてから、画面サイズを変えても安心して手を入れられるようになりました。

ゲーム本体は、ブラウザですぐ遊べます。

👉 引き継ぎラボ


業務アプリの画面でも、同じように崩れを機械で止める作りからご相談を承っています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?