156 lines
6.6 KiB
TypeScript
156 lines
6.6 KiB
TypeScript
import { expect, test, type Locator, type Page } from '@playwright/test'
|
||
|
||
/**
|
||
* 滑块验证码(L3,真浏览器)。
|
||
*
|
||
* 为什么必须在真实浏览器里跑:这一层的断言全是**几何**与**指针轨迹** —— 手柄最终停在哪、
|
||
* 进度条盖住多少、拖到容器外松手会不会收尾。happy-dom 没有布局引擎,单测只能把 rect
|
||
* 钉死后验逻辑(见 src/captcha/SliderCaptcha.test.ts)。
|
||
*
|
||
* 两个踩过的坑(写在这里防复发):
|
||
* 1. playground 是一整页长文档,滑块区在首屏之下,而 `boundingBox()` 返回**视口坐标** ——
|
||
* 不先 `scrollIntoViewIfNeeded()` 就往那儿发鼠标事件,会落到视口外,表现为「拖了没反应」;
|
||
* 2. 拖拽终点要**越过容器边缘**几像素:行程是按 padding box 算的,贴着边缘算容易因为
|
||
* 1px 取整「差一点不通过」,越界部分本来就会被组件夹住。
|
||
*
|
||
* 进 RTL 统一走 `?dir=rtl`(playground 写到 `<html dir>`),不依赖点击开关的顺序。
|
||
*/
|
||
|
||
const url = (dir: 'ltr' | 'rtl') => `/?dir=${dir}`
|
||
|
||
async function gotoDir(page: Page, dir: 'ltr' | 'rtl') {
|
||
await page.goto(url(dir))
|
||
await expect(page.locator('html')).toHaveAttribute('dir', dir)
|
||
}
|
||
|
||
async function isRtl(page: Page): Promise<boolean> {
|
||
return (await page.locator('html').getAttribute('dir')) === 'rtl'
|
||
}
|
||
|
||
/**
|
||
* 用真实鼠标把滑块从起点拖向行内结束端;`releaseRatio` 传 0.5 表示拖到一半就松手。
|
||
*/
|
||
async function dragHandle(
|
||
page: Page,
|
||
root: Locator,
|
||
options: { releaseRatio?: number } = {},
|
||
): Promise<void> {
|
||
await root.scrollIntoViewIfNeeded()
|
||
const rtl = await isRtl(page)
|
||
const handle = root.locator('.g3-slider-captcha__action')
|
||
const handleBox = (await handle.boundingBox())!
|
||
const rootBox = (await root.boundingBox())!
|
||
|
||
const y = handleBox.y + handleBox.height / 2
|
||
const startX = handleBox.x + handleBox.width / 2
|
||
// 越界 8px:行内结束端在 LTR 是右边缘、RTL 是左边缘
|
||
const endX = rtl ? rootBox.x - 8 : rootBox.x + rootBox.width + 8
|
||
const targetX = startX + (endX - startX) * (options.releaseRatio ?? 1)
|
||
|
||
await page.mouse.move(startX, y)
|
||
await page.mouse.down()
|
||
await page.mouse.move(targetX, y, { steps: 12 })
|
||
await page.mouse.up()
|
||
}
|
||
|
||
test.describe('SliderCaptcha', () => {
|
||
test('真实拖拽到末端即通过:进度 100、文案切换、状态回传', async ({ page }) => {
|
||
await page.goto('/')
|
||
const root = page.locator('[data-testid="slider-captcha"]')
|
||
const handle = root.locator('.g3-slider-captcha__action')
|
||
|
||
await root.scrollIntoViewIfNeeded()
|
||
await expect(root.locator('.g3-slider-captcha__text')).toHaveText('按住滑块拖动')
|
||
await expect(handle).toHaveAttribute('aria-valuenow', '0')
|
||
// 未通过时才可聚焦,通过后要让出 Tab 顺序
|
||
await expect(handle).toHaveAttribute('tabindex', '0')
|
||
|
||
await dragHandle(page, root)
|
||
|
||
await expect(root).toHaveClass(/is-passed/)
|
||
await expect(root.locator('.g3-slider-captcha__text')).toHaveText('验证通过')
|
||
await expect(handle).toHaveAttribute('aria-valuenow', '100')
|
||
await expect(handle).toHaveAttribute('tabindex', '-1')
|
||
await expect(page.locator('[data-testid="slider-captcha-state"]')).toHaveText('passed')
|
||
|
||
// 手柄右缘贴住轨道末端(取整误差 1px 内)
|
||
const rootBox = (await root.boundingBox())!
|
||
const handleBox = (await handle.boundingBox())!
|
||
expect(handleBox.x + handleBox.width).toBeLessThanOrEqual(rootBox.x + rootBox.width + 1)
|
||
// 进度条盖住手柄的一半
|
||
const barBox = (await root.locator('.g3-slider-captcha__bar').boundingBox())!
|
||
expect(barBox.width).toBeGreaterThan(rootBox.width - handleBox.width)
|
||
})
|
||
|
||
test('中途松手回弹到起点,不报通过', async ({ page }) => {
|
||
await page.goto('/')
|
||
const root = page.locator('[data-testid="slider-captcha"]')
|
||
const handle = root.locator('.g3-slider-captcha__action')
|
||
|
||
await dragHandle(page, root, { releaseRatio: 0.5 })
|
||
|
||
await expect(root).not.toHaveClass(/is-passed/)
|
||
await expect(page.locator('[data-testid="slider-captcha-state"]')).toHaveText('idle')
|
||
await expect(handle).toHaveAttribute('aria-valuenow', '0')
|
||
// 回弹有 300ms 过渡,用 poll 等它归位到行程起点
|
||
const rootBox = (await root.boundingBox())!
|
||
await expect
|
||
.poll(async () => (await handle.boundingBox())!.x, { timeout: 3000 })
|
||
.toBeLessThanOrEqual(rootBox.x + 2)
|
||
})
|
||
|
||
test('禁用态拖不动,且带 is-disabled 类', async ({ page }) => {
|
||
await page.goto('/')
|
||
const root = page.locator('[data-testid="slider-captcha-disabled"]')
|
||
const handle = root.locator('.g3-slider-captcha__action')
|
||
|
||
await root.scrollIntoViewIfNeeded()
|
||
await expect(root).toHaveClass(/is-disabled/)
|
||
await expect(handle).toHaveAttribute('tabindex', '-1')
|
||
|
||
await dragHandle(page, root)
|
||
await expect(root).not.toHaveClass(/is-passed/)
|
||
})
|
||
|
||
test('键盘:End 直达终点并通过,Home 复位', async ({ page }) => {
|
||
await page.goto('/')
|
||
const root = page.locator('[data-testid="slider-captcha"]')
|
||
const handle = root.locator('.g3-slider-captcha__action')
|
||
|
||
await root.scrollIntoViewIfNeeded()
|
||
await handle.focus()
|
||
await page.keyboard.press('End')
|
||
await expect(root).toHaveClass(/is-passed/)
|
||
await expect(page.locator('[data-testid="slider-captcha-state"]')).toHaveText('passed')
|
||
})
|
||
|
||
test.describe('RTL', () => {
|
||
test('手柄从右侧起步,向左拖到末端才通过', async ({ page }) => {
|
||
await gotoDir(page, 'rtl')
|
||
const root = page.locator('[data-testid="slider-captcha"]')
|
||
const handle = root.locator('.g3-slider-captcha__action')
|
||
|
||
await root.scrollIntoViewIfNeeded()
|
||
const rootBox = (await root.boundingBox())!
|
||
const handleBox = (await handle.boundingBox())!
|
||
// 起点贴行内起始边 = RTL 下的右边
|
||
expect(handleBox.x + handleBox.width).toBeGreaterThanOrEqual(rootBox.x + rootBox.width - 2)
|
||
|
||
// 未通过时的方向性图标按全库规则翻面(chevrons-right 在 RTL 下镜像);
|
||
// 必须在拖拽前断言 —— 通过后图标会换成 Check,那条镜像规则就不适用了
|
||
const iconTransform = await handle
|
||
.locator('svg')
|
||
.evaluate((el) => getComputedStyle(el).transform)
|
||
expect(iconTransform).toBe('matrix(-1, 0, 0, 1, 0, 0)')
|
||
|
||
await dragHandle(page, root)
|
||
|
||
await expect(root).toHaveClass(/is-passed/)
|
||
// 终点贴行内结束边 = RTL 下的左边
|
||
await expect
|
||
.poll(async () => (await handle.boundingBox())!.x, { timeout: 3000 })
|
||
.toBeLessThanOrEqual(rootBox.x + 2)
|
||
})
|
||
})
|
||
})
|