Skip to content

Commit e437be1

Browse files
Merge pull request #277 from OneBusAway/feat/stop-route-labels-256
Feat/stop-route-labels-256
2 parents 9577e1a + 0483514 commit e437be1

File tree

4 files changed

+548
-17
lines changed

4 files changed

+548
-17
lines changed
Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
import { render, screen } from '@testing-library/svelte';
2+
import userEvent from '@testing-library/user-event';
3+
import { expect, test, describe, vi } from 'vitest';
4+
import StopMarker from '../map/StopMarker.svelte';
5+
import { faBus } from '@fortawesome/free-solid-svg-icons';
6+
7+
describe('Route Labels Feature', () => {
8+
const mockStop = {
9+
id: 'stop_123',
10+
name: 'Test Stop',
11+
lat: 47.6062,
12+
lon: -122.3321,
13+
direction: 'N',
14+
routes: [
15+
{ id: 'route_1', shortName: '7', type: 3 },
16+
{ id: 'route_2', shortName: '10', type: 3 },
17+
{ id: 'route_3', shortName: '49', type: 3 }
18+
]
19+
};
20+
21+
const mockStopWithManyRoutes = {
22+
...mockStop,
23+
routes: [
24+
{ id: 'route_1', shortName: '7', type: 3 },
25+
{ id: 'route_2', shortName: '10', type: 3 },
26+
{ id: 'route_3', shortName: '49', type: 3 },
27+
{ id: 'route_4', shortName: '345', type: 3 },
28+
{ id: 'route_5', shortName: '522', type: 3 }
29+
]
30+
};
31+
32+
describe('StopMarker Component', () => {
33+
test('renders stop marker without route labels by default', () => {
34+
const onClick = vi.fn();
35+
render(StopMarker, {
36+
props: {
37+
stop: mockStop,
38+
onClick,
39+
icon: faBus,
40+
showRoutesLabel: false
41+
}
42+
});
43+
44+
expect(screen.queryByText(/7, 10, 49/)).not.toBeInTheDocument();
45+
});
46+
47+
test('shows route labels when showRoutesLabel is true', () => {
48+
const onClick = vi.fn();
49+
render(StopMarker, {
50+
props: {
51+
stop: mockStop,
52+
onClick,
53+
icon: faBus,
54+
showRoutesLabel: true
55+
}
56+
});
57+
58+
expect(screen.getByText(/7, 10, 49/)).toBeInTheDocument();
59+
});
60+
61+
test('displays up to 3 routes in collapsed state', () => {
62+
const onClick = vi.fn();
63+
render(StopMarker, {
64+
props: {
65+
stop: mockStopWithManyRoutes,
66+
onClick,
67+
icon: faBus,
68+
showRoutesLabel: true
69+
}
70+
});
71+
72+
expect(screen.getByText(/7, 10, 49/)).toBeInTheDocument();
73+
expect(screen.queryByText('345')).not.toBeInTheDocument();
74+
});
75+
76+
test('shows +N indicator and expand dots when there are more than 3 routes', () => {
77+
const onClick = vi.fn();
78+
render(StopMarker, {
79+
props: {
80+
stop: mockStopWithManyRoutes,
81+
onClick,
82+
icon: faBus,
83+
showRoutesLabel: true
84+
}
85+
});
86+
87+
expect(screen.getByText(/\+2/)).toBeInTheDocument();
88+
expect(screen.getByText('⋯')).toBeInTheDocument();
89+
});
90+
91+
test('expands to show all routes when clicked', async () => {
92+
const user = userEvent.setup();
93+
const onClick = vi.fn();
94+
95+
render(StopMarker, {
96+
props: {
97+
stop: mockStopWithManyRoutes,
98+
onClick,
99+
icon: faBus,
100+
showRoutesLabel: true
101+
}
102+
});
103+
104+
const routeLabel = screen.getByText(/7, 10, 49/).closest('.routes-label');
105+
await user.click(routeLabel);
106+
107+
// All routes should now be visible, including the previously hidden ones
108+
expect(screen.getByText(/345/)).toBeInTheDocument();
109+
expect(screen.getByText(/522/)).toBeInTheDocument();
110+
expect(screen.queryByText('⋯')).not.toBeInTheDocument();
111+
});
112+
113+
test('collapses back to 3 routes when clicked again', async () => {
114+
const user = userEvent.setup();
115+
const onClick = vi.fn();
116+
117+
render(StopMarker, {
118+
props: {
119+
stop: mockStopWithManyRoutes,
120+
onClick,
121+
icon: faBus,
122+
showRoutesLabel: true
123+
}
124+
});
125+
126+
const routeLabel = screen.getByText(/7, 10, 49/).closest('.routes-label');
127+
128+
await user.click(routeLabel);
129+
expect(screen.getByText(/345/)).toBeInTheDocument();
130+
131+
await user.click(routeLabel);
132+
expect(screen.queryByText(/345/)).not.toBeInTheDocument();
133+
expect(screen.getByText('⋯')).toBeInTheDocument();
134+
});
135+
136+
test('clicking route label does not trigger marker onClick', async () => {
137+
const user = userEvent.setup();
138+
const onClick = vi.fn();
139+
140+
render(StopMarker, {
141+
props: {
142+
stop: mockStopWithManyRoutes,
143+
onClick,
144+
icon: faBus,
145+
showRoutesLabel: true
146+
}
147+
});
148+
149+
const routeLabel = screen.getByText(/7, 10, 49/).closest('.routes-label');
150+
await user.click(routeLabel);
151+
152+
expect(onClick).not.toHaveBeenCalled();
153+
});
154+
155+
test('positions label at bottom when direction is north', () => {
156+
const onClick = vi.fn();
157+
render(StopMarker, {
158+
props: {
159+
stop: mockStop,
160+
onClick,
161+
icon: faBus,
162+
showRoutesLabel: true
163+
}
164+
});
165+
166+
const routeLabel = screen.getByText(/7, 10, 49/).closest('.routes-label');
167+
expect(routeLabel).toHaveClass('position-bottom');
168+
});
169+
170+
test('positions label at side when direction is south', () => {
171+
const onClick = vi.fn();
172+
const stopSouth = { ...mockStop, direction: 'S' };
173+
174+
render(StopMarker, {
175+
props: {
176+
stop: stopSouth,
177+
onClick,
178+
icon: faBus,
179+
showRoutesLabel: true
180+
}
181+
});
182+
183+
const routeLabel = screen.getByText(/7, 10, 49/).closest('.routes-label');
184+
expect(routeLabel).toHaveClass('position-side');
185+
});
186+
187+
test('applies expanded class when label is clicked', async () => {
188+
const user = userEvent.setup();
189+
const onClick = vi.fn();
190+
191+
render(StopMarker, {
192+
props: {
193+
stop: mockStopWithManyRoutes,
194+
onClick,
195+
icon: faBus,
196+
showRoutesLabel: true
197+
}
198+
});
199+
200+
const routeLabel = screen.getByText(/7, 10, 49/).closest('.routes-label');
201+
expect(routeLabel).not.toHaveClass('expanded');
202+
203+
await user.click(routeLabel);
204+
205+
expect(routeLabel).toHaveClass('expanded');
206+
});
207+
208+
test('handles stops with no routes', () => {
209+
const onClick = vi.fn();
210+
const stopNoRoutes = { ...mockStop, routes: [] };
211+
212+
render(StopMarker, {
213+
props: {
214+
stop: stopNoRoutes,
215+
onClick,
216+
icon: faBus,
217+
showRoutesLabel: true
218+
}
219+
});
220+
221+
expect(screen.queryByText(/routes-label/)).not.toBeInTheDocument();
222+
});
223+
});
224+
225+
describe('Map Provider Zoom Logic', () => {
226+
const ZOOM_THRESHOLD = 16;
227+
228+
test('labels should show at zoom level 16', () => {
229+
const zoom = 16;
230+
const shouldShow = zoom >= ZOOM_THRESHOLD;
231+
expect(shouldShow).toBe(true);
232+
});
233+
234+
test('labels should show above zoom level 16', () => {
235+
const zoom = 18;
236+
const shouldShow = zoom >= ZOOM_THRESHOLD;
237+
expect(shouldShow).toBe(true);
238+
});
239+
240+
test('labels should hide below zoom level 16', () => {
241+
const zoom = 15;
242+
const shouldShow = zoom >= ZOOM_THRESHOLD;
243+
expect(shouldShow).toBe(false);
244+
});
245+
246+
test('state tracking prevents unnecessary updates', () => {
247+
let routeLabelsVisible = false;
248+
const zoom = 17;
249+
const shouldShow = zoom >= ZOOM_THRESHOLD;
250+
251+
// First update - state changes
252+
if (routeLabelsVisible !== shouldShow) {
253+
routeLabelsVisible = shouldShow;
254+
}
255+
expect(routeLabelsVisible).toBe(true);
256+
257+
// Second update at same zoom - no change
258+
const secondUpdate = routeLabelsVisible !== shouldShow;
259+
expect(secondUpdate).toBe(false); // Should early exit
260+
});
261+
262+
test('batch updates all markers when crossing threshold', () => {
263+
const markers = new Map();
264+
markers.set('stop1', { props: { showRoutesLabel: false } });
265+
markers.set('stop2', { props: { showRoutesLabel: false } });
266+
markers.set('stop3', { props: { showRoutesLabel: false } });
267+
268+
const shouldShow = true;
269+
for (const marker of markers.values()) {
270+
if (marker?.props) {
271+
marker.props.showRoutesLabel = shouldShow;
272+
}
273+
}
274+
275+
markers.forEach((marker) => {
276+
expect(marker.props.showRoutesLabel).toBe(true);
277+
});
278+
});
279+
});
280+
281+
describe('Route Name Extraction', () => {
282+
test('uses shortName when available', () => {
283+
const route = { shortName: '7' };
284+
const name = route.shortName || route.code || null;
285+
expect(name).toBe('7');
286+
});
287+
288+
test('falls back to code when shortName missing', () => {
289+
const route = { code: 'A-Line' };
290+
const name = route.shortName || route.code || null;
291+
expect(name).toBe('A-Line');
292+
});
293+
294+
test('extracts from ID when shortName and code missing', () => {
295+
const route = { id: 'agency_route_49' };
296+
const name = String(route.id).split('_').pop();
297+
expect(name).toBe('49');
298+
});
299+
});
300+
301+
describe('Performance Optimization', () => {
302+
test('only updates when zoom crosses threshold boundary', () => {
303+
const THRESHOLD = 16;
304+
305+
// Zoom 14->15: both below, no update needed
306+
expect(14 >= THRESHOLD === 15 >= THRESHOLD).toBe(true);
307+
308+
// Zoom 15->16: crosses boundary, update needed
309+
expect(15 >= THRESHOLD === 16 >= THRESHOLD).toBe(false);
310+
311+
// Zoom 16->17: both above, no update needed
312+
expect(16 >= THRESHOLD === 17 >= THRESHOLD).toBe(true);
313+
});
314+
});
315+
});

0 commit comments

Comments
 (0)