|
| 1 | +import { Subject } from 'rxjs'; |
| 2 | +import { NavigationEnd, NavigationSkipped, Params, Router } from '@angular/router'; |
| 3 | +import { NavigationService } from './navigation.service'; |
| 4 | + |
| 5 | +/** |
| 6 | + * Constructed directly — no `TestBed` (the ui-core barrel pulls the whole app |
| 7 | + * graph). The router double records `navigate()` calls and lets each test |
| 8 | + * control the two inputs the service's scheduling depends on: the in-flight |
| 9 | + * navigation (`getCurrentNavigation`) and the settle events stream. |
| 10 | + */ |
| 11 | +interface RecordedNavigate { |
| 12 | + commands: unknown[]; |
| 13 | + extras: { queryParams?: Params; queryParamsHandling?: string; replaceUrl?: boolean }; |
| 14 | +} |
| 15 | + |
| 16 | +function createService(overrides: { currentNavigation?: () => unknown } = {}) { |
| 17 | + const events$ = new Subject<unknown>(); |
| 18 | + const navigations: RecordedNavigate[] = []; |
| 19 | + const router = { |
| 20 | + events: events$.asObservable(), |
| 21 | + getCurrentNavigation: overrides.currentNavigation ?? (() => null), |
| 22 | + navigate: jest.fn(async (commands: unknown[], extras: RecordedNavigate['extras']) => { |
| 23 | + navigations.push({ commands, extras }); |
| 24 | + return true; |
| 25 | + }) |
| 26 | + } as unknown as Router; |
| 27 | + const activatedRoute = {} as never; |
| 28 | + const destroyRef = { onDestroy: () => () => {} } as never; |
| 29 | + |
| 30 | + const service = new NavigationService(router, activatedRoute, destroyRef); |
| 31 | + return { service, router, events$, navigations }; |
| 32 | +} |
| 33 | + |
| 34 | +/** The flush is a macrotask — one real timer tick lets it run. */ |
| 35 | +const tick = () => new Promise<void>((resolve) => setTimeout(resolve)); |
| 36 | + |
| 37 | +describe('NavigationService — router-owned query-param writes', () => { |
| 38 | + it('writes through router.navigate with merge + replaceUrl (no history entry, tree stays in sync)', async () => { |
| 39 | + const { service, navigations } = createService(); |
| 40 | + |
| 41 | + await service.updateQueryParams({ date: '2026-08-01', unit_of_time: 'month' }); |
| 42 | + |
| 43 | + expect(navigations).toHaveLength(1); |
| 44 | + expect(navigations[0].commands).toEqual([]); |
| 45 | + expect(navigations[0].extras.queryParamsHandling).toBe('merge'); |
| 46 | + expect(navigations[0].extras.replaceUrl).toBe(true); |
| 47 | + expect(navigations[0].extras.queryParams).toEqual({ date: '2026-08-01', unit_of_time: 'month' }); |
| 48 | + }); |
| 49 | + |
| 50 | + it("maps '' and empty arrays to null — the router's removal — so 'All Teams' still removes ?teamId", async () => { |
| 51 | + const { service, navigations } = createService(); |
| 52 | + |
| 53 | + await service.updateQueryParams({ teamId: '', tags: [], archived: false }); |
| 54 | + |
| 55 | + expect(navigations[0].extras.queryParams).toEqual({ teamId: null, tags: null, archived: false }); |
| 56 | + }); |
| 57 | + |
| 58 | + it('deduplicates array values (the old buildQueryString contract)', async () => { |
| 59 | + const { service, navigations } = createService(); |
| 60 | + |
| 61 | + await service.updateQueryParams({ tags: ['a', 'b', 'a'] }); |
| 62 | + |
| 63 | + expect(navigations[0].extras.queryParams).toEqual({ tags: ['a', 'b'] }); |
| 64 | + }); |
| 65 | + |
| 66 | + it('coalesces a same-tick burst into ONE navigation, last value per key wins', async () => { |
| 67 | + const { service, navigations } = createService(); |
| 68 | + |
| 69 | + const first = service.updateQueryParams({ teamId: '', projectId: 'p1' }); |
| 70 | + const second = service.updateQueryParams({ teamId: 't2' }); |
| 71 | + await Promise.all([first, second]); |
| 72 | + |
| 73 | + expect(navigations).toHaveLength(1); |
| 74 | + expect(navigations[0].extras.queryParams).toEqual({ teamId: 't2', projectId: 'p1' }); |
| 75 | + }); |
| 76 | + |
| 77 | + it('parks the patch while a navigation is in flight and retries once it settles — never mid-flight', async () => { |
| 78 | + let inFlight: unknown = { id: 1 }; |
| 79 | + const { service, events$, navigations } = createService({ currentNavigation: () => inFlight }); |
| 80 | + |
| 81 | + const write = service.updateQueryParams({ date: '2026-08-01' }); |
| 82 | + await tick(); |
| 83 | + // Still in flight at flush time: nothing written, nothing lost. |
| 84 | + expect(navigations).toHaveLength(0); |
| 85 | + |
| 86 | + inFlight = null; |
| 87 | + events$.next(new NavigationEnd(1, '/pages/x', '/pages/x')); |
| 88 | + await write; |
| 89 | + |
| 90 | + expect(navigations).toHaveLength(1); |
| 91 | + expect(navigations[0].extras.queryParams).toEqual({ date: '2026-08-01' }); |
| 92 | + }); |
| 93 | + |
| 94 | + it('a NavigationSkipped settle also releases parked patches (idempotent re-write loop terminator)', async () => { |
| 95 | + let inFlight: unknown = { id: 1 }; |
| 96 | + const { service, events$, navigations } = createService({ currentNavigation: () => inFlight }); |
| 97 | + |
| 98 | + const write = service.updateQueryParams({ organizationId: 'org-1' }); |
| 99 | + await tick(); |
| 100 | + expect(navigations).toHaveLength(0); |
| 101 | + |
| 102 | + inFlight = null; |
| 103 | + events$.next(new NavigationSkipped(1, '/pages/x', 'ignored')); |
| 104 | + await write; |
| 105 | + |
| 106 | + expect(navigations).toHaveLength(1); |
| 107 | + }); |
| 108 | + |
| 109 | + it('a non-merge patch replaces the pending accumulation wholesale', async () => { |
| 110 | + const { service, navigations } = createService(); |
| 111 | + |
| 112 | + const first = service.updateQueryParams({ keepMe: 'no' }); |
| 113 | + const second = service.updateQueryParams({ onlyMe: 'yes' }, ''); |
| 114 | + await Promise.all([first, second]); |
| 115 | + |
| 116 | + expect(navigations).toHaveLength(1); |
| 117 | + expect(navigations[0].extras.queryParams).toEqual({ onlyMe: 'yes' }); |
| 118 | + }); |
| 119 | +}); |
0 commit comments