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: '
page
' })
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((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()
})
})