diff --git a/packages/frontend/test/lib/nirax/fallbacks.test.ts b/packages/frontend/test/lib/nirax/fallbacks.test.ts new file mode 100644 index 0000000000..fc18d78316 --- /dev/null +++ b/packages/frontend/test/lib/nirax/fallbacks.test.ts @@ -0,0 +1,90 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { assert, describe, test } from 'vitest'; +import { createRouter, loginFallbackComponent } from './fixture.js'; + +describe('[NIRAX] フォールバック', () => { + test('pushの際、ページが見つからなかったらforcePushを発火する', () => { + const router = createRouter('/'); + const forcePushes: string[] = []; + + router.addListener('forcePush', ctx => { + forcePushes.push(ctx.fullPath); + assert.strictEqual(ctx.onInit, false); + }); + + router.init(); + + router.pushByPath('/missing'); + + assert.deepStrictEqual(forcePushes, ['/missing']); + assert.strictEqual(router.getCurrentFullPath(), '/'); + assert.strictEqual(router.current.route.path, '/'); + }); + + test('replaceの際、ページが見つからなかったらforceReplaceを発火する', () => { + const router = createRouter('/'); + const forceReplacements: string[] = []; + + router.addListener('forceReplace', ctx => { + forceReplacements.push(ctx.fullPath); + assert.strictEqual(ctx.onInit, false); + }); + + router.init(); + + router.replaceByPath('/also-missing'); + + assert.deepStrictEqual(forceReplacements, ['/also-missing']); + assert.strictEqual(router.getCurrentFullPath(), '/'); + assert.strictEqual(router.current.route.path, '/'); + }); + + test('初期ページが見つからない場合でも初回はforceReplaceを発火しない', () => { + const router = createRouter('/missing'); + const forceReplacements: string[] = []; + + router.addListener('forceReplace', ctx => { + forceReplacements.push(ctx.fullPath); + assert.strictEqual(ctx.onInit, true); + }); + + router.init(); + + assert.deepStrictEqual(forceReplacements, []); // 初回はforceReplaceを発火しない + assert.strictEqual(router.getCurrentFullPath(), '/missing'); + assert.strictEqual(router.current.route.path, '/:(*)'); + }); + + test('初期ページが見つからない場合でも、initで明示した場合はforceReplaceを発火する', () => { + const router = createRouter('/missing'); + const forceReplacements: string[] = []; + + router.addListener('forceReplace', ctx => { + forceReplacements.push(ctx.fullPath); + assert.strictEqual(ctx.onInit, true); + }); + + router.init(true); // forceReplaceを強制的に発火させる + + assert.deepStrictEqual(forceReplacements, ['/missing']); + assert.strictEqual(router.getCurrentFullPath(), '/missing'); + assert.strictEqual(router.current.route.path, '/:(*)'); + }); + + test('loginRequiredなルートではコンポーネントを差し替えてshowLoginPopupを設定する', () => { + const router = createRouter('/', false); + + router.init(); + + router.pushByPath('/private'); + + assert.strictEqual(router.current.route.path, '/private'); + assert.ok('component' in router.current.route); + assert.strictEqual(router.current.route.component, loginFallbackComponent); + assert.strictEqual(router.current.props.get('showLoginPopup'), true); + }); +}); diff --git a/packages/frontend/test/lib/nirax/fixture.ts b/packages/frontend/test/lib/nirax/fixture.ts new file mode 100644 index 0000000000..ef6cf1ed9b --- /dev/null +++ b/packages/frontend/test/lib/nirax/fixture.ts @@ -0,0 +1,80 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { Component } from 'vue'; +import { Nirax } from '@/lib/nirax.js'; +import type { RouteDef } from '@/lib/nirax.js'; + +export const homeComponent = { name: 'home-page' } as Component; +export const postComponent = { name: 'post-page' } as Component; +export const fileComponent = { name: 'file-page' } as Component; +export const optionalComponent = { name: 'optional-page' } as Component; +export const userComponent = { name: 'user-page' } as Component; +export const followersComponent = { name: 'followers-page' } as Component; +export const privateComponent = { name: 'private-page' } as Component; +export const notFoundRouteComponent = { name: 'not-found-route' } as Component; +export const loginFallbackComponent = { name: 'login-fallback' } as Component; + +export const routes = [ + { + path: '/', + component: homeComponent, + }, + { + path: '/posts/:postId', + component: postComponent, + query: { + from: 'source', + }, + hash: 'section', + }, + { + path: '/files/:path(*)', + component: fileComponent, + }, + { + path: '/optional/:slug?', + component: optionalComponent, + }, + { + path: '/old', + redirect: '/posts/redirected', + }, + { + path: '/legacy/:postId', + redirect: props => `/posts/${props.get('postId')}`, + }, + { + path: '/loop-a', + redirect: '/loop-b', + }, + { + path: '/loop-b', + redirect: '/loop-a', + }, + { + path: '/user/:id', + component: userComponent, + children: [ + { + path: '/followers', + component: followersComponent, + }, + ], + }, + { + path: '/private', + component: privateComponent, + loginRequired: true, + }, + { + path: '/:(*)', + component: notFoundRouteComponent, + }, +] as const satisfies RouteDef[]; + +export function createRouter(currentFullPath = '/', isLoggedIn = true) { + return new Nirax(routes, currentFullPath, isLoggedIn, loginFallbackComponent); +} diff --git a/packages/frontend/test/lib/nirax/navigation.test.ts b/packages/frontend/test/lib/nirax/navigation.test.ts new file mode 100644 index 0000000000..b0fcedcc70 --- /dev/null +++ b/packages/frontend/test/lib/nirax/navigation.test.ts @@ -0,0 +1,105 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { assert, describe, test } from 'vitest'; +import { createRouter } from './fixture.js'; + +describe('[NIRAX] ナビゲーションイベント', () => { + test('init時にリダイレクトを解決してreplaceを発火する', () => { + const router = createRouter('/old?from=legacy#intro'); + const changes: string[] = []; + const replacements: string[] = []; + + router.addListener('change', ctx => { + changes.push(ctx.fullPath); + }); + router.addListener('replace', ctx => { + replacements.push(ctx.fullPath); + }); + + router.init(); + + assert.strictEqual(router.getCurrentFullPath(), '/posts/redirected?from=legacy#intro'); + assert.strictEqual(router.current.redirected, true); + assert.deepStrictEqual(changes, []); // 初回はchangeを発火しない + assert.deepStrictEqual(replacements, ['/posts/redirected?from=legacy#intro']); + }); + + test('push時に動的リダイレクトを解決してpushとchangeを発火する', () => { + const router = createRouter('/'); + const pushed: string[] = []; + const changed: string[] = []; + + router.addListener('push', ctx => { + pushed.push(ctx.fullPath); + assert.strictEqual(ctx.route?.path, '/posts/:postId'); + assert.strictEqual(ctx.props?.get('postId'), 'abc123'); + }); + router.addListener('change', ctx => { + changed.push(ctx.fullPath); + }); + + router.init(); + + router.pushByPath('/legacy/abc123'); + + assert.strictEqual(router.getCurrentFullPath(), '/posts/abc123'); + assert.deepStrictEqual(pushed, ['/posts/abc123']); + assert.deepStrictEqual(changed, ['/posts/abc123']); + }); + + test('無限リダイレクトはエラーになる', () => { + const router = createRouter('/'); + + router.init(); + + assert.throws(() => { + router.pushByPath('/loop-a'); + }, /redirect loop detected/); + assert.strictEqual(router.getCurrentFullPath(), '/'); + }); + + test('同じパスへの遷移ではsameを発火する', () => { + const router = createRouter('/posts/123'); + let sameCount = 0; + let pushCount = 0; + + router.addListener('same', () => { + sameCount++; + }); + router.addListener('push', () => { + pushCount++; + }); + + router.init(); + + router.pushByPath('/posts/123'); + + assert.strictEqual(sameCount, 1); + assert.strictEqual(pushCount, 0); // sameのときはpushを発火しない + }); + + test('navHookでナビゲーションをキャンセルできる', () => { + const router = createRouter('/posts/123'); + const navHookCalls: string[] = []; + let pushCount = 0; + + router.addListener('push', () => { + pushCount++; + }); + router.navHook = fullPath => { + navHookCalls.push(fullPath); + return true; + }; + + router.init(); + + router.pushByPath('/posts/456'); + + assert.deepStrictEqual(navHookCalls, ['/posts/456']); + assert.strictEqual(pushCount, 0); + assert.strictEqual(router.getCurrentFullPath(), '/posts/123'); + }); +}); diff --git a/packages/frontend/test/lib/nirax/resolve.test.ts b/packages/frontend/test/lib/nirax/resolve.test.ts new file mode 100644 index 0000000000..42cc0bae04 --- /dev/null +++ b/packages/frontend/test/lib/nirax/resolve.test.ts @@ -0,0 +1,73 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { assert, describe, test } from 'vitest'; +import { createRouter } from './fixture.js'; + +describe('[NIRAX] resolve', () => { + test('staticなルートを解決できる', () => { + const router = createRouter(); + const resolved = router.resolve('/'); + + assert.ok(resolved); + assert.strictEqual(resolved.route.path, '/'); + assert.strictEqual(resolved.props.size, 0); + }); + + test('パスパラメータ付きルートを解決できる', () => { + const router = createRouter(); + const resolved = router.resolve('/posts/abc%2Fdef'); + + assert.ok(resolved); + assert.strictEqual(resolved.route.path, '/posts/:postId'); + assert.strictEqual(resolved.props.get('postId'), 'abc/def'); + }); + + test('queryとhashのエイリアスを解決できる', () => { + const router = createRouter(); + const resolved = router.resolve('/posts/abc?from=timeline#thread'); + + assert.ok(resolved); + assert.strictEqual(resolved.props.get('source'), 'timeline'); + assert.strictEqual(resolved.props.get('section'), 'thread'); + }); + + test('wildcardルートのパラメータを解決できる', () => { + const router = createRouter(); + const resolved = router.resolve('/files/images/icons/logo%20mark.svg'); + + assert.ok(resolved); + assert.strictEqual(resolved.route.path, '/files/:path(*)'); + assert.strictEqual(resolved.props.get('path'), 'images/icons/logo mark.svg'); + }); + + test('optionalなパスパラメータが省略されたルートを解決できる', () => { + const router = createRouter(); + const resolved = router.resolve('/optional'); + + assert.ok(resolved); + assert.strictEqual(resolved.route.path, '/optional/:slug?'); + assert.strictEqual(resolved.props.has('slug'), false); + }); + + test('optionalなパスパラメータが存在するルートを解決できる', () => { + const router = createRouter(); + const resolved = router.resolve('/optional/topic'); + + assert.ok(resolved); + assert.strictEqual(resolved.props.get('slug'), 'topic'); + }); + + test('ネストされたルートを解決できる', () => { + const router = createRouter(); + const resolved = router.resolve('/user/alice/followers'); + + assert.ok(resolved); + assert.strictEqual(resolved.route.path, '/user/:id'); + assert.strictEqual(resolved.props.get('id'), 'alice'); + assert.ok(resolved.child); + assert.strictEqual(resolved.child.route.path, '/followers'); + }); +});