import {expect, describe, it, beforeEach, vi} from 'vitest'
import {render} from 'vitest-browser-react'
import React from 'react'
import {AppStackRouter, useAppStack, useCanPop, useStackSize} from './AppStackRouter'
function toEl(e: {element(): Element}): HTMLElement {
return e.element() as HTMLElement
}
// --- 测试用屏幕组件 ---
function Home() {
const {push} = useAppStack()
return (
Home
)
}
function Detail({id}: {id: number}) {
const {push, pop} = useAppStack()
return (
Detail {id}
)
}
function Sub() {
const {pop} = useAppStack()
return (
Sub
)
}
// 用于 keep-alive 状态验证:自增计数器
function Counter() {
const {push} = useAppStack()
const [count, setCount] = React.useState(0)
return (
{count}
)
}
function makeTouchEvent(
type: string,
x: number,
y: number,
): TouchEvent {
const touch = new Touch({
identifier: 0,
target: document.body,
clientX: x,
clientY: y,
})
return new TouchEvent(type, {
touches: [touch],
changedTouches: [touch],
bubbles: true,
cancelable: true,
})
}
describe('AppStackRouter', () => {
beforeEach(() => {
// 清理可能遗留的哨兵历史条目:用 replaceState 直接覆盖当前条目状态为 null,
// 避免使用 history.back()(异步,易触发循环/串扰)。组件挂载时会自行补哨兵。
if (typeof window !== 'undefined') {
window.history.replaceState(null, '')
}
})
it('渲染根屏幕,栈为空', async () => {
const {getByTestId, container} = render(
} />,
)
expect(toEl(getByTestId('home'))).toBeDefined()
expect(container.querySelector('[data-testid="detail"]')).toBeNull()
})
it('push 压入新屏幕且根屏幕 keep-alive 保留', async () => {
const {getByTestId} = render(} />)
// 初始只有 Home
expect(getByTestId('home')).toBeDefined()
// push Detail
await getByTestId('open-detail').click()
// Detail 出现,Home 仍在 DOM(keep-alive)
expect(toEl(getByTestId('detail')).textContent).toContain('Detail 1')
expect(toEl(getByTestId('home')).textContent).toContain('Home')
})
it('pop 后回到根屏幕,根屏幕状态未丢失', async () => {
const {getByTestId} = render(} />)
// 在根屏幕自增计数到 3
const inc = getByTestId('inc')
await inc.click()
await inc.click()
await inc.click()
expect(toEl(getByTestId('count-value')).textContent).toBe('3')
// push 到 Detail
await getByTestId('open-next').click()
expect(toEl(getByTestId('detail')).textContent).toContain('Detail 99')
// pop 回根
await getByTestId('back').click()
// 根屏幕计数仍是 3(状态保留)
await vi.waitFor(() => {
expect(toEl(getByTestId('count-value')).textContent).toBe('3')
})
})
it('replace 在栈顶屏幕内替换自身', async () => {
const Replaceable = () => {
const {replace} = useAppStack()
return (
)
}
const Root = () => {
const {push} = useAppStack()
return (
)
}
const {getByTestId, container} = render(
} />,
)
await getByTestId('push-replaceable').click()
expect(toEl(getByTestId('replaceable'))).toBeDefined()
await getByTestId('do-replace').click()
// 栈顶已变为 Detail(id:7),深度仍为 1
expect(toEl(getByTestId('detail')).textContent).toContain('Detail 7')
expect(container.querySelector('[data-testid="replaceable"]')).toBeNull()
})
it('canPop / size 反映栈状态', async () => {
const Root = () => {
const {push, canPop, size} = useAppStack()
return (
{String(canPop())}
{size}
)
}
const {getByTestId} = render(} />)
expect(toEl(getByTestId('canpop')).textContent).toBe('false')
expect(toEl(getByTestId('size')).textContent).toBe('0')
await getByTestId('push-btn').click()
// 进入 Detail 后,Detail 内可通过其按钮 pop;这里验证根屏幕的状态已变化
// 由于根屏幕被遮挡,改用 Detail 自身的 size 渲染来验证(Detail 已含 open-sub/back)
// 栈深度为 1
expect(toEl(getByTestId('detail'))).toBeDefined()
})
it('reset 清空堆栈回到根', async () => {
const ResetRoot = () => {
const {push} = useAppStack()
return (
)
}
// 栈顶屏幕内调用 reset,验证清空后回到根
const ResettableDetail = () => {
const {reset} = useAppStack()
return (
)
}
const {getByTestId, container} = render(
} />,
)
await getByTestId('push-detail').click()
expect(toEl(getByTestId('detail'))).toBeDefined()
await getByTestId('reset-btn').click()
await vi.waitFor(() => {
expect(container.querySelector('[data-testid="detail"]')).toBeNull()
expect(toEl(getByTestId('reset-root'))).toBeDefined()
})
})
it('useStackSize 响应式订阅栈深度', async () => {
const Root = () => {
const {push} = useAppStack()
const size = useStackSize()
return (
{size}
)
}
const {getByTestId} = render(} />)
expect(toEl(getByTestId('size')).textContent).toBe('0')
await getByTestId('push-btn').click()
// 根屏幕被遮挡,但 useStackSize 在根屏幕组件内,其 size 已更新为 1
// 通过 Detail 内的返回按钮 pop 后验证 size 回到 0
await getByTestId('back').click()
await vi.waitFor(() => {
expect(toEl(getByTestId('size')).textContent).toBe('0')
})
})
it('useCanPop 响应式订阅可出栈状态', async () => {
const Root = () => {
const {push} = useAppStack()
const canPop = useCanPop()
return (
{String(canPop)}
)
}
const {getByTestId} = render(} />)
expect(toEl(getByTestId('canpop')).textContent).toBe('false')
await getByTestId('push-btn').click()
await getByTestId('back').click()
await vi.waitFor(() => {
expect(toEl(getByTestId('canpop')).textContent).toBe('false')
})
})
it('safeArea 默认启用,容器含 env(safe-area-inset-*) 内边距', async () => {
const {container} = render(} />)
const wrapper = container.firstElementChild as HTMLElement
const style = wrapper.style
expect(style.paddingTop).toContain('env(safe-area-inset-top)')
expect(style.paddingBottom).toContain('env(safe-area-inset-bottom)')
expect(style.paddingLeft).toContain('env(safe-area-inset-left)')
expect(style.paddingRight).toContain('env(safe-area-inset-right)')
})
it('safeArea={false} 时不加安全区内边距', async () => {
const {container} = render(
} safeArea={false} />,
)
const wrapper = container.firstElementChild as HTMLElement
expect(wrapper.style.paddingTop).toBe('')
})
it('浏览器返回键触发 pop(模拟 popstate)', async () => {
const {getByTestId, container} = render(
} />,
)
await getByTestId('open-detail').click()
expect(toEl(getByTestId('detail'))).toBeDefined()
// 模拟用户按浏览器返回:触发 popstate
window.dispatchEvent(new PopStateEvent('popstate'))
await vi.waitFor(() => {
expect(container.querySelector('[data-testid="detail"]')).toBeNull()
expect(toEl(getByTestId('home'))).toBeDefined()
})
})
it('左滑超过阈值触发 pop', async () => {
const {container, getByTestId} = render(
} />,
)
const wrapper = container.firstElementChild as HTMLElement
await getByTestId('open-detail').click()
expect(toEl(getByTestId('detail'))).toBeDefined()
// 从左边缘开始,向右快速划过阈值:位移足够 + 向右瞬时速度
vi.useFakeTimers()
wrapper.dispatchEvent(makeTouchEvent('touchstart', 10, 50))
vi.advanceTimersByTime(16)
wrapper.dispatchEvent(makeTouchEvent('touchmove', 120, 50))
vi.advanceTimersByTime(16)
wrapper.dispatchEvent(makeTouchEvent('touchmove', 140, 50))
vi.advanceTimersByTime(8)
wrapper.dispatchEvent(makeTouchEvent('touchend', 140, 50))
vi.useRealTimers()
await vi.waitFor(() => {
expect(container.querySelector('[data-testid="detail"]')).toBeNull()
})
})
it('左滑未超过阈值回弹,不触发 pop', async () => {
const {container, getByTestId} = render(
} />,
)
const wrapper = container.firstElementChild as HTMLElement
await getByTestId('open-detail').click()
expect(toEl(getByTestId('detail'))).toBeDefined()
// 拖拽距离很小,未达阈值,且无明显速度
vi.useFakeTimers()
wrapper.dispatchEvent(makeTouchEvent('touchstart', 10, 50))
vi.advanceTimersByTime(16)
wrapper.dispatchEvent(makeTouchEvent('touchmove', 30, 50))
vi.advanceTimersByTime(16)
wrapper.dispatchEvent(makeTouchEvent('touchend', 30, 50))
vi.useRealTimers()
// 回弹后 Detail 仍在
await vi.waitFor(() => {
expect(toEl(getByTestId('detail'))).toBeDefined()
})
})
it('划过阈值后反向回划松手:默认识别为取消意图,回弹不出栈', async () => {
const {container, getByTestId} = render(
} />,
)
const wrapper = container.firstElementChild as HTMLElement
await getByTestId('open-detail').click()
expect(toEl(getByTestId('detail'))).toBeDefined()
// 先向右划 200px(超过阈值 80),再向左回划,松手时位置仍在阈值之上但运动方向为左
vi.useFakeTimers()
wrapper.dispatchEvent(makeTouchEvent('touchstart', 10, 50))
vi.advanceTimersByTime(16)
wrapper.dispatchEvent(makeTouchEvent('touchmove', 210, 50)) // 向右 200px
vi.advanceTimersByTime(16)
wrapper.dispatchEvent(makeTouchEvent('touchmove', 120, 50)) // 向左回划,瞬时方向为负
vi.advanceTimersByTime(8)
wrapper.dispatchEvent(makeTouchEvent('touchend', 120, 50)) // 松手位置 120 > 阈值 80
vi.useRealTimers()
// 尽管位置超过阈值,但松手朝取消方向,应回弹:Detail 仍在
await vi.waitFor(() => {
expect(toEl(getByTestId('detail'))).toBeDefined()
})
})
it('关闭 cancelOnReverseRelease 后,划过阈值再回划仍触发 pop', async () => {
const {container, getByTestId} = render(
} swipeBackCancelOnReverseRelease={false} />,
)
const wrapper = container.firstElementChild as HTMLElement
await getByTestId('open-detail').click()
expect(toEl(getByTestId('detail'))).toBeDefined()
// 同样的手势:划过阈值后回划,但关闭了取消意图识别 -> 仍出栈(位置超阈值)
vi.useFakeTimers()
wrapper.dispatchEvent(makeTouchEvent('touchstart', 10, 50))
vi.advanceTimersByTime(16)
wrapper.dispatchEvent(makeTouchEvent('touchmove', 210, 50))
vi.advanceTimersByTime(16)
wrapper.dispatchEvent(makeTouchEvent('touchmove', 120, 50))
vi.advanceTimersByTime(8)
wrapper.dispatchEvent(makeTouchEvent('touchend', 120, 50))
vi.useRealTimers()
await vi.waitFor(() => {
expect(container.querySelector('[data-testid="detail"]')).toBeNull()
})
})
it('在 Router 外调用 useAppStack 抛错', async () => {
// 抑制 console.error
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
const Outside = () => {
useAppStack()
return null
}
expect(() => render()).toThrow(/useAppStack\(\) must be used within/)
spy.mockRestore()
})
it('多个 AppStackRouter 实例栈相互隔离', async () => {
// 用不同 testid 的 root,并通过 container.querySelector 直接读取各实例 DOM,
// 避免 getByTestId 在 body 范围内的歧义
const Home1 = () => {
const {push} = useAppStack()
return (
)
}
const Home2 = () => {
const {push} = useAppStack()
return (
)
}
const {container: c1} = render(} />)
const {container: c2} = render(} />)
// 点击实例 1 的 open 按钮
const open1 = c1.querySelector('[data-testid="open1"]') as HTMLElement
open1.click()
// 实例 1 出现 detail,实例 2 不受影响
await vi.waitFor(() => {
expect(c1.querySelector('[data-testid="detail"]')).not.toBeNull()
expect(c2.querySelector('[data-testid="detail"]')).toBeNull()
})
expect(c2.querySelector('[data-testid="home2"]')).not.toBeNull()
})
})