mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 05:07:37 +08:00
cec041d241
From the router sweep:
- angular-spotify had 12 routes all named `/` and no navigation. Its lazy
routes are `async () => (await import('@ws/home')).HomeModule` through an
Nx library's `src/index.ts` barrel, which the mount pass never looked
through; and every lib is its own `src/`, so each routes file got its own
route table and no template's `routerLink` found the screen it names.
- jira-clone had 3 routes and no navigation: its issue route is
`issue/:${ProjectConst.IssueId}` (a class `static readonly`), its root
redirect lives in `app.routes.ts`, which only mounts, and
`navigate(['project', 'issue', id])` has no leading `/`.
Now: lazy mounts follow `export … from` barrels (and an NgModule's routing
imports); `(await import(x)).M` is read; route constants may be class
statics (strings or objects), enum members, or live behind a barrel, and a
template-literal path takes constant holes; a routes file that only mounts
contributes its redirects at its mount prefix; the route table is keyed by
the `angular.json` / `nx.json` workspace; and `router.navigate` /
`createUrlTree` without `relativeTo` resolve a bare first command from the
root, as Angular does (template `routerLink` stays relative).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
460 lines
22 KiB
TypeScript
460 lines
22 KiB
TypeScript
/**
|
|
* Angular Router: screens from `Routes` arrays, navigation from command
|
|
* arrays and `routerLink`, the component tree from templates.
|
|
*
|
|
* Validated on angular-realworld-example-app (standalone components, lazy
|
|
* `loadComponent`, a `loadChildren` routes file), ngx-admin (NgModule
|
|
* `loadChildren` through a routing module, layouts, redirects) and Ghostfolio
|
|
* (route constants with `$localize` paths, destructured locals, function
|
|
* constants, a `**` redirect home).
|
|
*/
|
|
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { CodeGraph } from '../src';
|
|
import { commandsHref, localizeDefault, parseAngularRoutes, staticString } from '../src/resolution/frameworks/angular-router';
|
|
import { buildScreens } from '../src/ui-server/api/screens';
|
|
import { buildSteps } from '../src/ui-server/api/steps';
|
|
|
|
describe('parseAngularRoutes', () => {
|
|
const file = (body: string) => `import { Routes } from '@angular/router';\n${body}`;
|
|
|
|
it('reads screens, joining children to their parents, and skips what is not a screen', () => {
|
|
const { routes, mounts, redirects } = parseAngularRoutes(
|
|
file(`export const routes: Routes = [
|
|
{ path: '', loadComponent: () => import('./home/home.component') },
|
|
{ path: 'login', component: AuthComponent, canActivate: [guard] },
|
|
{ path: 'editor', children: [
|
|
{ path: '', loadComponent: () => import('./editor.component').then((m) => m.EditorComponent) },
|
|
{ path: ':slug', loadComponent: () => import('./editor.component').then((m) => m.EditorComponent) },
|
|
] },
|
|
{ path: 'profile', loadChildren: () => import('./profile/profile.routes') },
|
|
{ path: 'users', matcher: usersMatcher, component: UsersComponent },
|
|
{ path: 'old', redirectTo: 'login' },
|
|
{ path: '**', component: NotFoundComponent },
|
|
];`)
|
|
);
|
|
expect(routes.map((r) => [r.path, r.component])).toEqual([
|
|
['/', { name: null, spec: './home/home.component' }],
|
|
['/login', { name: 'AuthComponent', spec: null }],
|
|
['/editor', { name: 'EditorComponent', spec: './editor.component' }],
|
|
['/editor/:slug', { name: 'EditorComponent', spec: './editor.component' }],
|
|
]);
|
|
expect(mounts.map((m) => [m.prefix, m.spec])).toEqual([['/profile', './profile/profile.routes']]);
|
|
expect(redirects).toEqual([{ from: '/old', to: '/login', absolute: false }]);
|
|
});
|
|
|
|
it('treats a route with children as a layout: a screen only where no child claims its address', () => {
|
|
const { routes } = parseAngularRoutes(
|
|
file(`const routes: Routes = [
|
|
{ path: ':username', component: ProfileComponent, children: [
|
|
{ path: '', loadComponent: () => import('./articles.component') },
|
|
{ path: 'favorites', loadComponent: () => import('./favorites.component') },
|
|
] },
|
|
{ path: 'pages', component: PagesComponent, children: [{ path: 'dashboard', component: DashboardComponent }] },
|
|
];
|
|
export default routes;`)
|
|
);
|
|
expect(routes.map((r) => [r.path, r.component?.spec ?? r.component?.name, r.layouts.map((l) => l.name)])).toEqual([
|
|
['/:username', './articles.component', ['ProfileComponent']],
|
|
['/:username/favorites', './favorites.component', ['ProfileComponent']],
|
|
['/pages/dashboard', 'DashboardComponent', ['PagesComponent']],
|
|
['/pages', 'PagesComponent', []],
|
|
]);
|
|
});
|
|
|
|
it('finds the arrays forRoot, forChild, provideRouter and a typed default export hold', () => {
|
|
for (const body of [
|
|
`RouterModule.forRoot([{ path: 'a', component: A }])`,
|
|
`RouterModule.forChild([{ path: 'a', component: A }])`,
|
|
`bootstrapApplication(App, { providers: [provideRouter([{ path: 'a', component: A }])] })`,
|
|
`export default [{ path: 'a', component: A }] satisfies Routes;`,
|
|
`const table = [{ path: 'a', component: A }] as Routes;`,
|
|
]) {
|
|
expect(parseAngularRoutes(file(body)).routes.map((r) => r.path)).toEqual(['/a']);
|
|
}
|
|
// An array that is not a routes array, in a file that imports the router.
|
|
expect(parseAngularRoutes(file(`const tabs = [{ path: 'a', component: A }];`)).routes).toEqual([]);
|
|
// No router import, no routes.
|
|
expect(parseAngularRoutes(`export const routes = [{ path: 'a', component: A }];`).routes).toEqual([]);
|
|
});
|
|
|
|
it('keeps a constant path as a placeholder for the cross-file pass, and reads $localize defaults', () => {
|
|
const { routes, redirects } = parseAngularRoutes(
|
|
file(`export const routes: Routes = [
|
|
{ path: internalRoutes.account.path, component: AccountComponent },
|
|
{ path: $localize\`:kebab-case@@routes.about:about\`, component: AboutComponent },
|
|
{ path: '**', redirectTo: 'home', pathMatch: 'full' },
|
|
];`)
|
|
);
|
|
expect(routes.map((r) => r.path)).toEqual(['/{internalRoutes.account.path}', '/about']);
|
|
expect(redirects).toEqual([{ from: '/**', to: '/home', absolute: false }]);
|
|
});
|
|
});
|
|
|
|
describe('Angular destinations', () => {
|
|
it('reads a static string: literals, $localize defaults and + between them', () => {
|
|
expect(staticString(`'/' + $localize\`:kebab-case@@routes.about:about\``)).toBe('/about');
|
|
expect(staticString(`'/editor/' + slug`)).toBeNull();
|
|
expect(localizeDefault('$localize`Access`')).toBe('Access');
|
|
});
|
|
|
|
it('reads an absolute command array, holes for what is computed, and nothing for a relative or all-hole one', () => {
|
|
expect(commandsHref(`['/article', article.slug]`)?.display).toBe('/article/${…}');
|
|
expect(commandsHref(`['/profile', p.username, 'favorites']`)?.display).toBe('/profile/${…}/favorites');
|
|
expect(commandsHref(`['/']`)?.display).toBe('/');
|
|
expect(commandsHref(`['../', id]`)).toBeNull();
|
|
expect(commandsHref(`['edit']`)).toBeNull();
|
|
// Nothing static: it would match any route of its length.
|
|
expect(commandsHref('[`/${a}`, b, c]')).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// End to end
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const projects: string[] = [];
|
|
afterAll(() => {
|
|
for (const p of projects.splice(0)) fs.rmSync(p, { recursive: true, force: true });
|
|
});
|
|
|
|
const component = (name: string, selector: string, template: string, body = '') => `import { Component } from '@angular/core';
|
|
import { Router } from '@angular/router';
|
|
@Component({
|
|
selector: '${selector}',
|
|
${template.startsWith('./') ? `templateUrl: '${template}'` : `template: \`${template}\``},
|
|
})
|
|
export class ${name} {
|
|
constructor(private readonly router: Router) {}
|
|
${body}
|
|
}
|
|
`;
|
|
|
|
describe('an Angular app, indexed', () => {
|
|
let cg: CodeGraph;
|
|
let root: string;
|
|
beforeAll(async () => {
|
|
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-angular-'));
|
|
projects.push(root);
|
|
const files: Record<string, string> = {
|
|
'package.json': JSON.stringify({ dependencies: { '@angular/core': '^19.0.0', '@angular/router': '^19.0.0' } }),
|
|
'src/app/routes.constants.ts': `export const internalRoutes = {
|
|
account: { path: 'account', routerLink: ['/account'], subRoutes: { access: { path: 'access', routerLink: (id: string) => ['/account', 'access', id] } } },
|
|
about: { path: $localize\`:kebab-case@@routes.about:about\`, routerLink: ['/' + $localize\`:kebab-case@@routes.about:about\`] },
|
|
};
|
|
`,
|
|
'src/app/app.routes.ts': `import { Routes } from '@angular/router';
|
|
import { AuthComponent } from './auth/auth.component';
|
|
import { internalRoutes } from './routes.constants';
|
|
const { about } = internalRoutes;
|
|
export const routes: Routes = [
|
|
{ path: 'home', loadComponent: () => import('./home/home.component') },
|
|
{ path: 'login', component: AuthComponent },
|
|
{ path: about.path, loadComponent: () => import('./about/about.component').then((m) => m.AboutComponent) },
|
|
{ path: internalRoutes.account.path, loadChildren: () => import('./account/account.routes') },
|
|
{ path: 'admin', loadChildren: () => import('./admin/admin.module').then((m) => m.AdminModule) },
|
|
{ path: '**', redirectTo: 'home' },
|
|
];
|
|
`,
|
|
'src/app/account/account.routes.ts': `import { Routes } from '@angular/router';
|
|
import { AccountComponent } from './account.component';
|
|
import { AccessComponent } from './access.component';
|
|
import { internalRoutes } from '../routes.constants';
|
|
export default [
|
|
{ path: '', component: AccountComponent, children: [
|
|
{ path: '', loadComponent: () => import('./overview.component') },
|
|
{ path: internalRoutes.account.subRoutes.access.path + '/:id', component: AccessComponent },
|
|
] },
|
|
] satisfies Routes;
|
|
`,
|
|
// An NgModule's routes live in the routing module it imports.
|
|
'src/app/admin/admin.module.ts': `import { NgModule } from '@angular/core';
|
|
import { AdminRoutingModule } from './admin-routing.module';
|
|
@NgModule({ imports: [AdminRoutingModule] })
|
|
export class AdminModule {}
|
|
`,
|
|
'src/app/admin/admin-routing.module.ts': `import { NgModule } from '@angular/core';
|
|
import { RouterModule, Routes } from '@angular/router';
|
|
import { AdminUsersComponent } from './admin-users.component';
|
|
const routes: Routes = [{ path: '', redirectTo: 'users', pathMatch: 'full' }, { path: 'users', component: AdminUsersComponent }];
|
|
@NgModule({ imports: [RouterModule.forChild(routes)], exports: [RouterModule] })
|
|
export class AdminRoutingModule {}
|
|
`,
|
|
'src/app/home/home.component.ts': component('HomeComponent', 'app-home', './home.component.html', ` open(slug: string) { this.router.navigate(['/login']); }
|
|
more() { this.router.navigate([], { queryParams: { page: 2 } }); }`).replace('export class', 'export default class'),
|
|
'src/app/home/home.component.html': `<app-card></app-card>
|
|
<a routerLink="/login">Sign in</a>
|
|
<a [routerLink]="routerLinkAbout">About</a>
|
|
<a [routerLink]="['/account', 'access', 7]">Access</a>
|
|
<a routerLink="relative">Relative</a>
|
|
`,
|
|
'src/app/shared/card.component.ts': component(
|
|
'CardComponent',
|
|
'app-card',
|
|
`<button (click)="save()">Save</button><input [(ngModel)]="name" /><a (click)="closed.emit(true)">x</a>`,
|
|
` name = '';
|
|
save() { this.router.navigate(internalRoutes.account.subRoutes.access.routerLink('me')); }`
|
|
).replace(
|
|
"import { Router } from '@angular/router';",
|
|
"import { Router } from '@angular/router';\nimport { internalRoutes } from '../routes.constants';"
|
|
),
|
|
'src/app/auth/auth.component.ts': component('AuthComponent', 'app-auth', `<a routerLink="/home">Home</a>`, ` done() { this.router.navigate(['/']); }`),
|
|
'src/app/about/about.component.ts': component('AboutComponent', 'app-about', `<p>About</p>`),
|
|
// A tab bar built in the class, handed to a tabs component that binds each `tab.routerLink`.
|
|
'src/app/account/account.component.ts': component(
|
|
'AccountComponent',
|
|
'app-account',
|
|
`<a [routerLink]="['/login']">Log out</a><app-tabs [tabs]="tabs"></app-tabs><router-outlet></router-outlet>`,
|
|
` tabs = [{ label: 'Access', routerLink: internalRoutes.account.subRoutes.access.routerLink('me') }];`
|
|
).replace("import { Router } from '@angular/router';", "import { Router } from '@angular/router';\nimport { internalRoutes } from '../routes.constants';"),
|
|
'src/app/account/overview.component.ts': component('OverviewComponent', 'app-overview', `<p>Overview</p>`).replace('export class', 'export default class'),
|
|
'src/app/account/access.component.ts': component('AccessComponent', 'app-access', `<p>Access</p>`),
|
|
'src/app/admin/admin-users.component.ts': component('AdminUsersComponent', 'app-admin-users', `<p>Users</p>`),
|
|
};
|
|
files['src/app/home/home.component.ts'] = files['src/app/home/home.component.ts']!.replace(
|
|
"import { Router } from '@angular/router';",
|
|
"import { Router } from '@angular/router';\nimport { internalRoutes } from '../routes.constants';"
|
|
).replace(' constructor(', ' protected readonly routerLinkAbout = internalRoutes.about.routerLink;\n constructor(');
|
|
for (const [rel, content] of Object.entries(files)) {
|
|
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
|
|
fs.writeFileSync(path.join(root, rel), content);
|
|
}
|
|
cg = await CodeGraph.init(root, { index: true });
|
|
});
|
|
afterAll(() => cg.close());
|
|
|
|
const routeOf = (id: string) => cg.getNode(id)?.name;
|
|
|
|
it('names each screen by the path a user takes to it, bound to its component', () => {
|
|
const routes = cg
|
|
.getNodesByKind('route')
|
|
.map((r) => {
|
|
const bound = cg.getOutgoingEdgesFrom([r.id], ['references']).map((e) => `${(e.metadata as Record<string, unknown>)?.layout ? 'layout ' : ''}${cg.getNode(e.target)?.name}`);
|
|
return `${r.name} -> ${bound.sort().join(', ')}`;
|
|
})
|
|
.sort();
|
|
expect(routes).toEqual([
|
|
'/about -> AboutComponent',
|
|
'/account -> OverviewComponent, layout AccountComponent',
|
|
'/account/access/:id -> AccessComponent, layout AccountComponent',
|
|
'/admin/users -> AdminUsersComponent',
|
|
'/home -> HomeComponent',
|
|
'/login -> AuthComponent',
|
|
]);
|
|
});
|
|
|
|
it('draws navigation from calls and routerLink, through constants, properties and redirects', () => {
|
|
const navigations = cg
|
|
.getNodesByKind('route')
|
|
.flatMap((r) => cg.getIncomingEdgesTo([r.id], ['navigates']).map((e) => `${cg.getNode(e.source)?.name} -> ${r.name} (${(e.metadata as Record<string, unknown>).navMethod})`))
|
|
.sort();
|
|
expect(navigations).toEqual([
|
|
// `navigate(['/'])`: `/` matches no route, and the root `**` redirect sends it home.
|
|
'AccountComponent -> /account/access/:id (routerLink)',
|
|
'AccountComponent -> /login (routerLink)',
|
|
'AuthComponent -> /home (routerLink)',
|
|
'HomeComponent -> /about (routerLink)',
|
|
'HomeComponent -> /account/access/:id (routerLink)',
|
|
'HomeComponent -> /login (routerLink)',
|
|
'done -> /home (navigate)',
|
|
'open -> /login (navigate)',
|
|
// A route constant that builds its commands: `access.routerLink('me')`.
|
|
'save -> /account/access/:id (navigate)',
|
|
]);
|
|
});
|
|
|
|
it('links a template to the components it renders', () => {
|
|
const renders = cg
|
|
.getNodesByKind('class')
|
|
.flatMap((c) =>
|
|
cg
|
|
.getOutgoingEdgesFrom([c.id], ['calls'])
|
|
.filter((e) => (e.metadata as Record<string, unknown> | undefined)?.synthesizedBy === 'angular-template')
|
|
.map((e) => `${c.name} -> ${cg.getNode(e.target)?.name} <${(e.metadata as Record<string, unknown>).via}>`)
|
|
);
|
|
expect(renders).toEqual(['HomeComponent -> CardComponent <app-card>']);
|
|
});
|
|
|
|
it("links a template's event bindings to the component's own methods, the binding as the trigger", async () => {
|
|
const bindings = cg
|
|
.getNodesByKind('class')
|
|
.flatMap((c) =>
|
|
cg
|
|
.getOutgoingEdgesFrom([c.id], ['calls'])
|
|
.filter((e) => (e.metadata as Record<string, unknown> | undefined)?.synthesizedBy === 'angular-event')
|
|
.map((e) => {
|
|
const meta = e.metadata as Record<string, unknown>;
|
|
return `${c.name} -> ${cg.getNode(e.target)?.name} ${JSON.stringify(meta.trigger)}`;
|
|
})
|
|
);
|
|
// `[(ngModel)]` is a two-way binding and `closed.emit()` raises the component's own output: neither calls a method.
|
|
expect(bindings).toEqual(['CardComponent -> save {"kind":"prop","name":"(click)","of":"button"}']);
|
|
|
|
const home = cg.getNodesByKind('route').find((r) => r.name === '/home')!;
|
|
const payload = await buildSteps(cg, root, new URLSearchParams({ anchor: home.id }));
|
|
const save = payload.steps.find((st) => st.label === 'save');
|
|
expect(save?.trigger).toMatchObject({ kind: 'prop', name: '(click)', of: 'button', in: 'CardComponent' });
|
|
});
|
|
|
|
it("draws the Screens picture: a child component's navigation on its screen, a layout's on each screen inside it", async () => {
|
|
const payload = await buildScreens(cg, root);
|
|
expect(payload.routed).toBe(true);
|
|
const links = payload.links.map((l) => `${routeOf(l.from) ?? cg.getNode(l.from)?.name} -> ${routeOf(l.to)}${l.via.length ? ` via ${l.via.map((v) => v.name).join('>')}` : ''}`).sort();
|
|
expect(links).toEqual([
|
|
'/account -> /account/access/:id',
|
|
'/account -> /login',
|
|
'/account/access/:id -> /account/access/:id',
|
|
'/account/access/:id -> /login',
|
|
'/home -> /about',
|
|
'/home -> /account/access/:id',
|
|
'/home -> /account/access/:id via CardComponent>save',
|
|
'/home -> /login',
|
|
'/home -> /login via open',
|
|
'/login -> /home',
|
|
'/login -> /home via done',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('an Angular workspace split across libraries', () => {
|
|
const roots: string[] = [];
|
|
afterAll(() => {
|
|
for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true });
|
|
});
|
|
|
|
async function project(files: Record<string, string>): Promise<CodeGraph> {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-angular-ws-'));
|
|
roots.push(root);
|
|
for (const [rel, content] of Object.entries(files)) {
|
|
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
|
|
fs.writeFileSync(path.join(root, rel), content);
|
|
}
|
|
return CodeGraph.init(root, { index: true });
|
|
}
|
|
const routeNames = (cg: CodeGraph) => cg.getNodesByKind('route').map((n) => n.name).sort();
|
|
const navs = (cg: CodeGraph) =>
|
|
cg
|
|
.getNodesByKind('route')
|
|
.flatMap((r) => cg.getIncomingEdges(r.id).filter((e) => e.kind === 'navigates').map((e) => `${cg.getNode(e.source)!.name} -> ${r.name}`))
|
|
.sort();
|
|
const component = (name: string, selector: string, template: string) => `import { Component } from '@angular/core';
|
|
@Component({ selector: '${selector}', template: \`${template}\` })
|
|
export class ${name} {}
|
|
`;
|
|
|
|
it('angular-spotify: Nx libraries behind barrels, `(await import(…)).M`, a class-static path constant', async () => {
|
|
const cg = await project({
|
|
'package.json': JSON.stringify({ name: 'ws', dependencies: { '@angular/core': '*', '@angular/router': '*' } }),
|
|
'nx.json': '{}',
|
|
'tsconfig.base.json': JSON.stringify({
|
|
compilerOptions: {
|
|
paths: {
|
|
'@ws/home': ['libs/home/src/index.ts'],
|
|
'@ws/lyrics': ['libs/lyrics/src/index.ts'],
|
|
'@ws/utils': ['libs/utils/src/index.ts'],
|
|
},
|
|
},
|
|
}),
|
|
'libs/utils/src/index.ts': `export * from './lib/router-util';\n`,
|
|
'libs/utils/src/lib/router-util.ts': `export class RouterUtil {
|
|
static Configuration = { Lyrics: 'lyrics' };
|
|
}
|
|
`,
|
|
'libs/shell/src/lib/shell.routes.ts': `import { Route } from '@angular/router';
|
|
import { RouterUtil } from '@ws/utils';
|
|
|
|
export const shellRoutes: Route[] = [
|
|
{ path: '', loadChildren: async () => (await import('@ws/home')).HomeModule },
|
|
{ path: RouterUtil.Configuration.Lyrics, loadChildren: async () => (await import('@ws/lyrics')).LyricsModule },
|
|
];
|
|
`,
|
|
'libs/home/src/index.ts': `export * from './lib/home.module';\n`,
|
|
'libs/home/src/lib/home.module.ts': `import { NgModule } from '@angular/core';
|
|
import { RouterModule } from '@angular/router';
|
|
import { HomeComponent } from './home.component';
|
|
@NgModule({ imports: [RouterModule.forChild([{ path: '', component: HomeComponent }])] })
|
|
export class HomeModule {}
|
|
`,
|
|
'libs/home/src/lib/home.component.ts': component('HomeComponent', 'as-home', '<a routerLink="/lyrics">Lyrics</a>'),
|
|
'libs/lyrics/src/index.ts': `export * from './lib/lyrics.module';\n`,
|
|
'libs/lyrics/src/lib/lyrics.module.ts': `import { NgModule } from '@angular/core';
|
|
import { RouterModule } from '@angular/router';
|
|
import { LyricsComponent } from './lyrics.component';
|
|
@NgModule({ imports: [RouterModule.forChild([{ path: '', component: LyricsComponent }])] })
|
|
export class LyricsModule {}
|
|
`,
|
|
'libs/lyrics/src/lib/lyrics.component.ts': component('LyricsComponent', 'as-lyrics', '<a routerLink="/">Home</a>'),
|
|
});
|
|
try {
|
|
expect(routeNames(cg)).toEqual(['/', '/lyrics']);
|
|
// A template in one library links to a screen another library declares.
|
|
expect(navs(cg)).toEqual(['HomeComponent -> /lyrics', 'LyricsComponent -> /']);
|
|
} finally {
|
|
cg.close();
|
|
}
|
|
});
|
|
|
|
it('jira-clone: a template-literal path, a redirect in a file that only mounts, navigate() from the root', async () => {
|
|
const cg = await project({
|
|
'package.json': JSON.stringify({ name: 'jira', dependencies: { '@angular/core': '*', '@angular/router': '*' } }),
|
|
'angular.json': '{}',
|
|
'src/app/app.routes.ts': `import { Routes } from '@angular/router';
|
|
export const appRoutes: Routes = [
|
|
{ path: 'project', loadChildren: () => import('./project/project.routes').then((m) => m.PROJECT_ROUTES) },
|
|
{ path: '', redirectTo: 'project', pathMatch: 'full' },
|
|
];
|
|
`,
|
|
'src/app/project/config/const.ts': `export class ProjectConst {
|
|
static readonly IssueId = 'issueId';
|
|
}
|
|
`,
|
|
'src/app/project/project.routes.ts': `import { Routes } from '@angular/router';
|
|
import { ProjectComponent } from './project.component';
|
|
import { BoardComponent } from './board.component';
|
|
import { IssueComponent } from './issue.component';
|
|
import { ProjectConst } from './config/const';
|
|
export const PROJECT_ROUTES: Routes = [
|
|
{
|
|
path: '',
|
|
component: ProjectComponent,
|
|
children: [
|
|
{ path: 'board', component: BoardComponent },
|
|
{ path: \`issue/:\${ProjectConst.IssueId}\`, component: IssueComponent },
|
|
{ path: '', redirectTo: 'board', pathMatch: 'full' },
|
|
],
|
|
},
|
|
];
|
|
`,
|
|
'src/app/project/project.component.ts': component('ProjectComponent', 'j-project', '<router-outlet></router-outlet>'),
|
|
'src/app/project/board.component.ts': `import { Component } from '@angular/core';
|
|
import { Router } from '@angular/router';
|
|
@Component({ selector: 'j-board', template: '<div></div>' })
|
|
export class BoardComponent {
|
|
constructor(private _router: Router) {}
|
|
openIssuePage(issueId: string) {
|
|
this._router.navigate(['project', 'issue', issueId]);
|
|
}
|
|
}
|
|
`,
|
|
'src/app/project/issue.component.ts': `import { Component } from '@angular/core';
|
|
import { Router } from '@angular/router';
|
|
@Component({ selector: 'j-issue', template: '<div></div>' })
|
|
export class IssueComponent {
|
|
constructor(private _router: Router) {}
|
|
backHome() {
|
|
this._router.navigate(['/']);
|
|
}
|
|
}
|
|
`,
|
|
});
|
|
try {
|
|
expect(routeNames(cg)).toEqual(['/project/board', '/project/issue/:issueId']);
|
|
expect(navs(cg)).toEqual(['backHome -> /project/board', 'openIssuePage -> /project/issue/:issueId']);
|
|
} finally {
|
|
cg.close();
|
|
}
|
|
});
|
|
});
|