Files
maestro/ui/e2e/space-header-focus-scroll.spec.ts
oss-sync 57702b4f34
CI / build-and-test (push) Canceled after 0s
sync: update from private repo (2336949a)
2026-09-17 11:29:31 +00:00

282 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 狭い画面で、キーボードの焦点が帯の外にある操作子を引き込むこと(issue #1379)。
*
* ワークスペースの帯の右側(名前・鉛筆・顔・ゴミ箱)は横に流れる箱に入っている。
* 指やマウスなら横になぞって出せるが、**焦点を移しただけでは箱が動かず**、削除
* ボタンが帯からはみ出したまま押される画面があった(430 ピクセル前後。はみ出し
* 始める幅は文字の計量で機械ごとに前後する)。
*
* ここで見るのは 3 つ。
* 1. Tab で焦点を移したら、**手で目一杯なぞったときと同じところまで**見える
* 2. 縦は動かない(`block: 'nearest'`。外すとページ全体が縦に飛ぶ)
* 3. **マウスで押したときは箱が動かない**(押した瞬間にずれると押し間違える)
*
* **「割合 1.00」を基準にしない。**流れる箱の内側の幅が削除ボタンより狭い画面が
* あり(実測で箱 27px / ボタン 28px)、そこでは指でどれだけなぞっても 1 にならない。
* 中身の幅はフォントの計量で機械ごとに変わるので、絶対値を焼き込むと別の機械で
* 嘘になる。基準は**利用者が手で到達できるいちばん良い位置**に取る。
*
* jsdom は幅を測れないので、1 と 3 の「本当に見えているか」は実機でしか分からない。
* 配線(帯の行に処理が付いているか)は SpaceDetail.focusScroll.test.tsx が見る。
*/
import { test, expect, type Page } from '@playwright/test';
/** 案件ワークスペースを 1 つ作って開く(削除ボタンは案件ワークスペースにだけ出る)。 */
async function createAndOpenCaseSpace(page: Page): Promise<void> {
await page.goto('/ui');
await page.getByTestId('nav-spaces').click();
const rail = page.getByTestId('space-rail');
await expect(rail).toBeVisible();
await page.getByTestId('create-space-btn').click();
const titleInput = page.getByTestId('space-title-input');
await expect(titleInput).toBeVisible();
const title = `帯の焦点-${Date.now()}`;
await titleInput.fill(title);
await page.getByTestId('space-form-submit').click();
await expect(titleInput).toHaveCount(0);
const row = rail
.locator('[data-testid="space-row"][data-space-kind="case"]')
.filter({ hasText: title });
await expect(row).toBeVisible();
await row.click();
await expect(page.getByTestId('space-detail')).toBeVisible();
}
/** 幅を変えたら 1 フレーム置いてから測る(space-header-badge.spec.ts と同じ理由)。 */
async function resizeAndSettle(page: Page, width: number): Promise<void> {
await page.setViewportSize({ width, height: 720 });
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
}
interface Measured {
/** 削除ボタンのうち、流れる箱の中に見えている割合。1 = 全部見えている。 */
ratio: number;
/** 横に流れる箱をどれだけなぞった状態か。 */
scrollLeft: number;
/** これ以上なぞれない位置(`scrollWidth - clientWidth`)。 */
maxScrollLeft: number;
/** 帯の上端(縦に飛んでいないかを見る)。 */
rowTop: number;
/** ページ全体の縦位置。 */
pageScrollTop: number;
/** いま焦点があるのが削除ボタンか。 */
deleteFocused: boolean;
/** 削除ボタンの右端(はみ出し量を報告に書くため)。 */
right: number;
}
async function measure(page: Page): Promise<Measured> {
return page.evaluate(() => {
const btn = document.querySelector('[data-testid="space-delete"]');
if (!btn) throw new Error('削除ボタンが出ていない');
const box = document.querySelector('[data-testid="space-header-meta"]') as HTMLElement;
const row = box.parentElement!;
const rr = row.getBoundingClientRect();
// 切り取られるのは**流れる箱の縁**であって、帯の行の縁ではない(行には右余白が
// あるので、行で測ると数ピクセルぶん甘く出る)。
const cr = box.getBoundingClientRect();
const br = btn.getBoundingClientRect();
const seen = Math.max(0, Math.min(br.right, cr.right) - Math.max(br.left, cr.left));
return {
ratio: Number((seen / br.width).toFixed(3)),
scrollLeft: Number(box.scrollLeft.toFixed(1)),
maxScrollLeft: Number((box.scrollWidth - box.clientWidth).toFixed(1)),
rowTop: Number(rr.top.toFixed(1)),
pageScrollTop: Number(document.scrollingElement!.scrollTop.toFixed(1)),
deleteFocused: document.activeElement === btn,
right: Number(br.right.toFixed(1)),
};
});
}
/** なぞった位置を 0 に戻す(前の幅で引き込んだ分を持ち越さないため)。 */
async function resetScroll(page: Page): Promise<void> {
await page.evaluate(() => {
(document.querySelector('[data-testid="space-header-meta"]') as HTMLElement).scrollLeft = 0;
});
}
/**
* 手で目一杯なぞったときに、削除ボタンがどこまで見えるかを測る。
*
* これが**利用者に到達できるいちばん良い位置**であり、引き込みの合否はここを基準に
* 判定する。絶対値 1.00 は基準にできない(冒頭の説明を参照)。
*/
async function measureManualBest(page: Page): Promise<Measured> {
await page.evaluate(() => {
const box = document.querySelector('[data-testid="space-header-meta"]') as HTMLElement;
box.scrollLeft = box.scrollWidth;
});
return measure(page);
}
/**
* 本物の Tab で削除ボタンまで進む。
*
* **`locator.focus()` を使わない。**あれは押鍵を発生させないので、
* 「キーボードのときだけ引き込む」判定を通り抜けてしまい、判定が壊れていても
* 緑のままになる。
*/
async function tabToDeleteButton(page: Page): Promise<number> {
for (let i = 1; i <= 40; i++) {
await page.keyboard.press('Tab');
const onDelete = await page.evaluate(
() => document.activeElement?.getAttribute('data-testid') === 'space-delete',
);
if (onDelete) return i;
}
throw new Error('Tab を 40 回押しても削除ボタンに届かなかった');
}
/** 帯の中の、押しても画面が変わらないところへ焦点を戻す(Tab の出発点)。 */
async function resetFocusToTabStrip(page: Page): Promise<void> {
await page.getByTestId('space-tab-chat').click();
}
const WIDTHS = [429, 430, 435, 439, 445];
/**
* 測り方の丸め(`toFixed(3)`)とサブピクセルのぶんの遊び。
* 引き込みが手なぞりと同じ位置に着いても、割合が 0.001 単位でずれることがある。
*/
const RATIO_EPSILON = 0.01;
/**
* 削除ボタンが実際にはみ出す幅を 1 つ選ぶ。
*
* **430 を決め打ちにしない。**はみ出し始める幅は文字の計量で機械ごとに動くので、
* 決め打ちにすると別の機械では「はみ出していないところ」を測って空振りする。
*/
async function findOverflowWidth(page: Page): Promise<number> {
for (const width of WIDTHS) {
await resizeAndSettle(page, width);
await resetFocusToTabStrip(page);
await resetScroll(page);
const m = await measure(page);
if (m.ratio < 1) return width;
}
throw new Error('どの幅でも削除ボタンがはみ出さない(この網の前提が崩れている)');
}
test.describe('ワークスペースの帯: キーボードの焦点は画面外の操作子を引き込む', () => {
test('Tab で削除ボタンへ移ると、手でなぞれるところまで引き込む(縦は動かない)', async ({ page }) => {
await createAndOpenCaseSpace(page);
for (const width of WIDTHS) {
await resizeAndSettle(page, width);
await resetFocusToTabStrip(page);
await resetScroll(page);
const before = await measure(page);
// 手で目一杯なぞった結果を先に測る。これが合否の基準。
const manual = await measureManualBest(page);
await resetScroll(page);
await tabToDeleteButton(page);
const after = await measure(page);
// eslint-disable-next-line no-console -- 実測値を報告に残す
console.log(
`[#1379] ${width}px 焦点前 割合=${before.ratio} なぞり=${before.scrollLeft} 右端=${before.right}` +
` / 手なぞり最大 割合=${manual.ratio} なぞり=${manual.scrollLeft}(上限=${manual.maxScrollLeft})` +
` / 焦点後 割合=${after.ratio} なぞり=${after.scrollLeft} 右端=${after.right}`,
);
expect(after.deleteFocused, `${width}px で削除ボタンに焦点が来ていない`).toBe(true);
expect(
after.ratio,
`${width}px で、手でなぞれるところまで引き込めていない(手なぞり最大 ${manual.ratio})`,
).toBeGreaterThanOrEqual(Math.min(1, manual.ratio) - RATIO_EPSILON);
// 縦は 1 ピクセルも動かさない。
expect(after.rowTop, `${width}px で帯が縦に動いた`).toBe(before.rowTop);
expect(after.pageScrollTop, `${width}px でページが縦に飛んだ`).toBe(before.pageScrollTop);
// はみ出していた幅では、実際に引き込んでいること(網が空振りしていない証拠)。
if (before.ratio < 1) {
expect(after.scrollLeft, `${width}px ではみ出していたのに箱が動いていない`).toBeGreaterThan(
before.scrollLeft,
);
}
}
});
test('はみ出す幅が実在する(この網が空振りしていないこと)', async ({ page }) => {
// **この網は引き込みの仕組みを判定していない。**焦点を 1 度も動かさないので、
// 引き込みを丸ごと外しても緑のまま。ここで確かめるのは上の網の前提——
// 「焦点を当てなければ実際にはみ出す幅がある」——だけ。
await createAndOpenCaseSpace(page);
const overflowed: number[] = [];
for (const width of WIDTHS) {
await resizeAndSettle(page, width);
await resetFocusToTabStrip(page);
await resetScroll(page);
const before = await measure(page);
if (before.ratio < 1) overflowed.push(width);
}
// eslint-disable-next-line no-console -- 実測値を報告に残す
console.log(`[#1379] 焦点なしではみ出す幅: ${overflowed.join(', ') || 'なし'}`);
expect(overflowed.length, 'どの幅でもはみ出さないなら、上の網は何も守っていない').toBeGreaterThan(0);
});
test('同じ場面で、キーボードなら動きマウスなら動かない', async ({ page }) => {
await createAndOpenCaseSpace(page);
// はみ出す幅を実測で選ぶ(決め打ちにすると別の機械で空振りする)。
const width = await findOverflowWidth(page);
// --- キーボードの側。ここが無いと「いつでも動かない」と区別が付かない ---
// マウス側だけを見る網は、引き込みを丸ごと外しても緑になる(動かないのが
// 当たり前だから)。同じ幅・同じボタンで「押鍵なら動く」を対にして測る。
await resetFocusToTabStrip(page);
await resetScroll(page);
const beforeKey = await measure(page);
await tabToDeleteButton(page);
const afterKey = await measure(page);
// --- マウスの側 ---
await resetFocusToTabStrip(page);
await resetScroll(page);
const beforeMouse = await measure(page);
// **`locator.click()` を使わない。**Playwright は押す前に要素を画面内へ
// 引き込むので、こちらの処理が何もしなくても箱が動いてしまう。見えている
// ところを座標で押す(=利用者が実際にできること)。
const spot = await page.evaluate(() => {
const btn = document.querySelector('[data-testid="space-delete"]')!.getBoundingClientRect();
const box = document
.querySelector('[data-testid="space-header-meta"]')!
.getBoundingClientRect();
const right = Math.min(btn.right, box.right);
return { x: (btn.left + right) / 2, y: btn.top + btn.height / 2 };
});
await page.mouse.move(spot.x, spot.y);
await page.mouse.down();
const onPress = await measure(page);
await page.mouse.up();
// eslint-disable-next-line no-console -- 実測値を報告に残す
console.log(
`[#1379] ${width}px キーボード なぞり=${beforeKey.scrollLeft}→${afterKey.scrollLeft}` +
` / マウス なぞり=${beforeMouse.scrollLeft}→${onPress.scrollLeft} 焦点=${onPress.deleteFocused}`,
);
expect(afterKey.deleteFocused, 'Tab で削除ボタンに焦点が来ていない').toBe(true);
expect(
afterKey.scrollLeft,
'キーボードでも箱が動いていない(引き込みが効いていない)',
).toBeGreaterThan(beforeKey.scrollLeft);
expect(onPress.deleteFocused, 'マウスで押しても焦点が来ていない(この網は空振り)').toBe(true);
expect(onPress.scrollLeft, 'マウスで押した瞬間に箱が動いた').toBe(beforeMouse.scrollLeft);
// 押せていること(見えている端を押しただけで確認の段に進む)。
await expect(page.getByTestId('space-delete-confirm')).toBeVisible();
});
});