194 lines
8.0 KiB
TypeScript
194 lines
8.0 KiB
TypeScript
import { createApp, defineComponent, nextTick } from 'vue'
|
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { createNativeRouter, definePresentation, shouldCommitGesture } from './runtime'
|
|
|
|
const Page = defineComponent({ template: '<div>page</div>' })
|
|
|
|
async function harness(blockB: boolean | 'redirect' = false) {
|
|
const router = createRouter({
|
|
history: createMemoryHistory(),
|
|
routes: [
|
|
{ path: '/a', component: Page },
|
|
{ path: '/b', component: Page, meta: { native: { parent: '/a' } } },
|
|
{ path: '/c', component: Page, meta: { native: { parent: '/a' } } },
|
|
{ path: '/modal', component: Page, meta: { native: { presentation: 'sheet', parent: '/a' } } },
|
|
{ path: '/left', component: Page, meta: { native: { siblingOrder: 0, siblingHistory: 'replace' } } },
|
|
{ path: '/middle', component: Page, meta: { native: { siblingOrder: 1, siblingHistory: 'replace' } } },
|
|
{ path: '/right', component: Page, meta: { native: { siblingOrder: 2, siblingHistory: 'replace' } } },
|
|
],
|
|
})
|
|
if (blockB) router.beforeEach((to) => to.path === '/b' ? (blockB === 'redirect' ? '/modal' : false) : undefined)
|
|
await router.push('/a')
|
|
await router.isReady()
|
|
const native = createNativeRouter({ router, cache: { maxInactive: 2 } })
|
|
const app = createApp(Page)
|
|
app.use(router)
|
|
app.use(native)
|
|
await nextTick()
|
|
return { router, native }
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.spyOn(window, 'matchMedia').mockReturnValue({ matches: true } as MediaQueryList)
|
|
})
|
|
|
|
describe('gesture decisions', () => {
|
|
it('uses progress or a deliberate velocity to commit', () => {
|
|
expect(shouldCommitGesture(0.4, 0)).toBe(true)
|
|
expect(shouldCommitGesture(0.12, 0.7)).toBe(true)
|
|
expect(shouldCommitGesture(0.04, 1.4)).toBe(false)
|
|
expect(shouldCommitGesture(0.2, 0.1)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('native router transactions', () => {
|
|
it('preloads a target without changing URL history', async () => {
|
|
const { router, native } = await harness()
|
|
const id = await native.beginInteractive('push', '/b')
|
|
expect(id).not.toBeNull()
|
|
expect(router.currentRoute.value.path).toBe('/a')
|
|
expect(native.entries.value.some((entry) => entry.status === 'preview')).toBe(true)
|
|
await native.cancelInteractive()
|
|
expect(router.currentRoute.value.path).toBe('/a')
|
|
expect(native.transaction.value).toBeNull()
|
|
})
|
|
|
|
it('commits a loaded preview through Vue Router', async () => {
|
|
const { router, native } = await harness()
|
|
await native.beginInteractive('push', '/b')
|
|
native.updateInteractive(0.55, 0.1)
|
|
expect(await native.finishInteractive()).toBe(true)
|
|
expect(router.currentRoute.value.path).toBe('/b')
|
|
expect(native.entries.value.filter((entry) => entry.status === 'active')).toHaveLength(1)
|
|
})
|
|
|
|
it('snaps back and removes the preview when a guard rejects commit', async () => {
|
|
const { router, native } = await harness(true)
|
|
await native.beginInteractive('push', '/b')
|
|
native.updateInteractive(0.8, 0)
|
|
expect(await native.finishInteractive()).toBe(false)
|
|
expect(router.currentRoute.value.path).toBe('/a')
|
|
expect(native.entries.value.some((entry) => entry.route.path === '/b')).toBe(false)
|
|
})
|
|
|
|
it('uses declared parents for cold-start predictive back', async () => {
|
|
const { router, native } = await harness()
|
|
await native.replace('/b', { presentation: 'none' })
|
|
const transaction = await native.beginInteractive('pop')
|
|
expect(transaction).not.toBeNull()
|
|
expect(native.transaction.value?.direction).toBe('back')
|
|
await native.cancelInteractive()
|
|
expect(router.currentRoute.value.path).toBe('/b')
|
|
})
|
|
|
|
it('discards the stale preview when Vue Router redirects a commit', async () => {
|
|
const { router, native } = await harness('redirect')
|
|
await native.beginInteractive('push', '/b')
|
|
expect(await native.finishInteractive(true)).toBe(true)
|
|
expect(router.currentRoute.value.path).toBe('/modal')
|
|
expect(native.entries.value.some((entry) => entry.status === 'preview')).toBe(false)
|
|
})
|
|
|
|
it('registers application-defined presentations', async () => {
|
|
const { native } = await harness()
|
|
const presentation = definePresentation({ name: 'flip', axis: 'x', layerStyle: () => ({ opacity: 0.5 }) })
|
|
native.registerPresentation(presentation)
|
|
expect(native.presentationFor('flip')).toBe(presentation)
|
|
})
|
|
|
|
it('treats navigation to the active route as a strict no-op', async () => {
|
|
const { router, native } = await harness()
|
|
const entries = [...native.entries.value]
|
|
expect(await native.push('/a')).toBe(false)
|
|
expect(await native.replace('/a')).toBe(false)
|
|
expect(router.currentRoute.value.path).toBe('/a')
|
|
expect(native.transaction.value).toBeNull()
|
|
expect(native.entries.value).toEqual(entries)
|
|
})
|
|
|
|
it('derives sibling direction from route order and uses adjacent-page motion', async () => {
|
|
const { native } = await harness()
|
|
await native.replace('/middle', { presentation: 'none' })
|
|
|
|
await native.beginInteractive('sibling', '/left', { replace: true })
|
|
expect(native.transaction.value).toMatchObject({ direction: 'back', presentation: 'slide' })
|
|
await native.cancelInteractive()
|
|
|
|
await native.beginInteractive('sibling', '/right', { replace: true })
|
|
expect(native.transaction.value).toMatchObject({ direction: 'forward', presentation: 'slide' })
|
|
await native.cancelInteractive()
|
|
})
|
|
|
|
it('keeps replaced sibling views cached but out of the back stack', async () => {
|
|
const { native } = await harness()
|
|
await native.replace('/left', { presentation: 'none' })
|
|
await native.sibling('/middle', { replace: true })
|
|
await native.push('/c')
|
|
|
|
await native.beginInteractive('pop')
|
|
const target = native.entries.value.find((entry) => entry.key === native.transaction.value?.toKey)
|
|
expect(target?.route.path).toBe('/middle')
|
|
expect(target?.route.path).not.toBe('/left')
|
|
await native.cancelInteractive()
|
|
})
|
|
|
|
it('does not preview a stale forward entry after pop then push', async () => {
|
|
const { native } = await harness()
|
|
await native.push('/b')
|
|
await native.pop()
|
|
await native.push('/c')
|
|
|
|
await native.beginInteractive('pop')
|
|
const target = native.entries.value.find((entry) => entry.key === native.transaction.value?.toKey)
|
|
expect(target?.route.path).toBe('/a')
|
|
await native.cancelInteractive()
|
|
})
|
|
|
|
it('dismisses with the presented route animation regardless of the route below it', async () => {
|
|
const { native } = await harness()
|
|
await native.push('/b')
|
|
await native.present('/modal', 'sheet')
|
|
|
|
await native.beginInteractive('dismiss')
|
|
expect(native.transaction.value).toMatchObject({ direction: 'back', presentation: 'sheet' })
|
|
await native.cancelInteractive()
|
|
})
|
|
|
|
it('refuses to overlap a second transaction with an active gesture', async () => {
|
|
const { native } = await harness()
|
|
const first = await native.beginInteractive('push', '/b')
|
|
expect(await native.beginInteractive('push', '/c')).toBeNull()
|
|
expect(native.transaction.value?.id).toBe(first)
|
|
await native.cancelInteractive()
|
|
})
|
|
|
|
it('accepts imperative navigation as the previous transition finalizes', async () => {
|
|
const { router, native } = await harness()
|
|
await native.beginInteractive('push', '/b')
|
|
const finishing = native.finishInteractive(true)
|
|
const queued = native.push('/c')
|
|
expect(await finishing).toBe(true)
|
|
expect(await queued).toBe(true)
|
|
expect(router.currentRoute.value.path).toBe('/c')
|
|
expect(native.transaction.value).toBeNull()
|
|
})
|
|
|
|
it('reconciles direct browser back navigation with the native stack', async () => {
|
|
const { router, native } = await harness()
|
|
await native.push('/b')
|
|
const navigated = new Promise<void>((resolve) => {
|
|
const remove = router.afterEach(() => {
|
|
remove()
|
|
resolve()
|
|
})
|
|
})
|
|
router.back()
|
|
await navigated
|
|
|
|
expect(router.currentRoute.value.path).toBe('/a')
|
|
expect(native.canGoBack.value).toBe(false)
|
|
expect(await native.beginInteractive('pop')).toBeNull()
|
|
})
|
|
})
|