This commit is contained in:
oneao committed 2026-10-11 22:14:13 +08:00
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)
})
})
})