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() }) })