u
This commit is contained in:
1 parent
32d423b759
commit
78eb811ff7
102 files changed
+12067
-209
No files matched your search
@@ -0,0 +1,155 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user