mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c0e39e287 |
@@ -138,6 +138,34 @@ describe('TransitSearchPanel', () => {
|
||||
expect(payload.metadata.transit.legs[1]).toMatchObject({ mode: 'SUBWAY', line: 'U2', line_color: '#FF3300', headsign: 'Ruhleben' })
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSIT-003b: routes Google answered name Google Maps and are saved as Google’s (#2850)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onAdd = vi.fn().mockResolvedValue({})
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY], provider: 'google' })
|
||||
render(<TransitSearchPanel {...makeProps({ onAdd })} />)
|
||||
await pickFromAndTo(user)
|
||||
await user.click(screen.getByRole('button', { name: /^Search$/ }))
|
||||
await user.click(await screen.findByText(/08:30 – 09:00/))
|
||||
|
||||
// Google's terms want the words as written, so the browser must not translate them.
|
||||
expect(screen.getByText('Google Maps')).toHaveAttribute('translate', 'no')
|
||||
expect(screen.queryByRole('link', { name: 'Transitous' })).not.toBeInTheDocument()
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add to day' }))
|
||||
await waitFor(() => expect(onAdd).toHaveBeenCalled())
|
||||
expect(onAdd.mock.calls[0][0].metadata.transit.provider).toBe('google')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSIT-003c: routes Transitous answered credit Transitous with its sources link', async () => {
|
||||
const user = userEvent.setup()
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY], provider: 'transitous' })
|
||||
render(<TransitSearchPanel {...makeProps()} />)
|
||||
await pickFromAndTo(user)
|
||||
await user.click(screen.getByRole('button', { name: /^Search$/ }))
|
||||
expect(await screen.findByRole('link', { name: 'Transitous' })).toHaveAttribute('href', 'https://transitous.org/sources/')
|
||||
expect(screen.queryByText('Google Maps')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSIT-004: search failure shows the empty state, not a crash', async () => {
|
||||
const user = userEvent.setup()
|
||||
transitApiMock.plan.mockRejectedValueOnce(new Error('boom'))
|
||||
|
||||
@@ -604,7 +604,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
notes: null,
|
||||
metadata: {
|
||||
transit: {
|
||||
provider: 'transitous',
|
||||
provider: provider ?? 'transitous',
|
||||
duration: it.duration,
|
||||
transfers: it.transfers,
|
||||
walk_seconds: it.walkSeconds,
|
||||
@@ -760,7 +760,10 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
))}
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', textAlign: 'center', marginTop: 2 }}>
|
||||
{t('transit.attribution')}{' '}
|
||||
<a href="https://transitous.org/sources/" target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline' }}>Transitous</a>
|
||||
{/* Named after the backend that answered; Google's terms want "Google Maps" as written, never translated. */}
|
||||
{provider === 'google'
|
||||
? <span translate="no">Google Maps</span>
|
||||
: <a href="https://transitous.org/sources/" target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline' }}>Transitous</a>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { haversineKm } from '../common/geo';
|
||||
import { localParts, resolveTimeZone } from '../common/timezoneService';
|
||||
import type { EndpointInput } from '../reservations/reservations.service';
|
||||
import { deriveTransitStats, type TransitItinerary, type TransitLeg } from './transit.helpers';
|
||||
import type { TransitProvider } from '@trek/shared';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -205,10 +206,12 @@ function timezoneAt(lat: number, lng: number): string {
|
||||
return timezone;
|
||||
}
|
||||
|
||||
/** `provider` is the backend the itinerary came from, recorded with the journey. */
|
||||
export function buildTransitReservationParts(
|
||||
from: TransitPlaceInput,
|
||||
to: TransitPlaceInput,
|
||||
itinerary: TransitItinerary,
|
||||
provider: TransitProvider,
|
||||
) {
|
||||
// The same stop appears up to three times below (transfer endpoint + the
|
||||
// adjacent legs' metadata) — memoise the tz lookup per coordinate pair.
|
||||
@@ -276,7 +279,7 @@ export function buildTransitReservationParts(
|
||||
const stats = deriveTransitStats(itinerary.startTime, itinerary.endTime, itinerary.legs, itinerary.transfers);
|
||||
const metadata = {
|
||||
transit: {
|
||||
provider: 'transitous',
|
||||
provider,
|
||||
duration: stats.duration,
|
||||
transfers: stats.transfers,
|
||||
walk_seconds: stats.walkSeconds,
|
||||
|
||||
@@ -110,7 +110,7 @@ export class TransitMcp {
|
||||
@Tool({
|
||||
name: 'search_transit_routes',
|
||||
description:
|
||||
'Search scheduled public-transit routes between two coordinates, via whichever backend the instance is configured for. Returns itineraries that can be passed unchanged to create_transit_journey. `dropped` counts provider itineraries that failed validation and are therefore absent from the results — a non-zero value means the provider offered routes this tool could not represent.',
|
||||
'Search scheduled public-transit routes between two coordinates, via whichever backend the instance is configured for. Returns itineraries that can be passed unchanged to create_transit_journey, and `provider`, the backend that answered. `dropped` counts provider itineraries that failed validation and are therefore absent from the results — a non-zero value means the provider offered routes this tool could not represent.',
|
||||
inputSchema: {
|
||||
from: transitPlaceInput,
|
||||
to: transitPlaceInput,
|
||||
@@ -169,7 +169,7 @@ export class TransitMcp {
|
||||
// A rejected itinerary is provider data we could not vouch for, but dropping it
|
||||
// silently is indistinguishable from "no routes exist" — report the count so the
|
||||
// caller knows the difference.
|
||||
return ok({ itineraries, dropped: result.itineraries.length - itineraries.length });
|
||||
return ok({ itineraries, dropped: result.itineraries.length - itineraries.length, provider: result.provider });
|
||||
} catch (err) {
|
||||
return errorResult(err, 'Transit route search failed.');
|
||||
}
|
||||
@@ -228,9 +228,11 @@ export class TransitMcp {
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
// The itinerary came from search_transit_routes, which the active backend answered.
|
||||
const provider = await this.transit.activeProvider(ctx.userId);
|
||||
let reservationParts: ReturnType<typeof buildTransitReservationParts>;
|
||||
try {
|
||||
reservationParts = buildTransitReservationParts(from, to, cleaned);
|
||||
reservationParts = buildTransitReservationParts(from, to, cleaned, provider);
|
||||
} catch (err) {
|
||||
return errorResult(err, 'Unable to resolve the transit journey timezones.');
|
||||
}
|
||||
|
||||
@@ -159,6 +159,11 @@ export class TransitService {
|
||||
@Inject(transitConfig.KEY) private readonly transitEnv: ConfigType<typeof transitConfig>,
|
||||
) {}
|
||||
|
||||
/** The backend that answers this user's searches: Google where it is set up and keyed, Transitous otherwise. */
|
||||
async activeProvider(userId = 0): Promise<TransitProvider> {
|
||||
return (await this.google.isActive(userId)) ? 'google' : 'transitous';
|
||||
}
|
||||
|
||||
/**
|
||||
* Station/place search for the from/to pickers. `near` biases results.
|
||||
*
|
||||
@@ -175,7 +180,7 @@ export class TransitService {
|
||||
const text = (query || '').trim();
|
||||
// Answered before either backend is consulted, so it reports the one that
|
||||
// WOULD have been asked rather than claiming nobody was.
|
||||
const provider: TransitProvider = (await this.google.isActive(userId)) ? 'google' : 'transitous';
|
||||
const provider = await this.activeProvider(userId);
|
||||
if (text.length < 2) return { results: [], provider };
|
||||
if (text.length > 200) {
|
||||
const e = new Error('Query too long') as Error & { status: number };
|
||||
@@ -259,7 +264,7 @@ export class TransitService {
|
||||
|
||||
// After validation on purpose: whichever backend answers, the caller is held
|
||||
// to the same coordinate/mode/transfer contract and gets the same 400s.
|
||||
if (await this.google.isActive(userId)) {
|
||||
if ((await this.activeProvider(userId)) === 'google') {
|
||||
return { ...(await this.google.plan(q, language, userId)), provider: 'google' };
|
||||
}
|
||||
|
||||
|
||||
@@ -201,6 +201,7 @@ describe('MCP transit tools', () => {
|
||||
expect(routes.itineraries[0].legs[0].from.name).toBe('Namba');
|
||||
expect(routes.itineraries[0].legs[1].to.name).toBe('Umeda');
|
||||
expect(routes.itineraries).toHaveLength(1);
|
||||
expect(routes.provider).toBe('transitous');
|
||||
expect(planMock).toHaveBeenCalledWith(expect.objectContaining({ modes: 'SUBWAY' }), undefined, user.id);
|
||||
|
||||
const invalidNear = await harness.client.callTool({
|
||||
@@ -244,6 +245,27 @@ describe('MCP transit tools', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('records Google as the provider of a journey planned while Google answers searches (#2850)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { start_date: '2026-12-03', end_date: '2026-12-04' });
|
||||
const day = await dayOf(trip.id, '2026-12-03');
|
||||
const active = vi.spyOn(TransitService.prototype, 'activeProvider').mockResolvedValue('google');
|
||||
try {
|
||||
await withHarness(user.id, ['reservations:write'], async (harness) => {
|
||||
const result = parseToolResult(
|
||||
await harness.client.callTool({
|
||||
name: 'create_transit_journey',
|
||||
arguments: { tripId: trip.id, dayId: day.id, from, to, itinerary },
|
||||
}),
|
||||
) as { reservation: { metadata: string } };
|
||||
expect(JSON.parse(result.reservation.metadata).transit.provider).toBe('google');
|
||||
});
|
||||
expect(active).toHaveBeenCalledWith(user.id);
|
||||
} finally {
|
||||
active.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects dateless, mismatched, and out-of-range journey dates', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const datelessTrip = createTrip(testDb, user.id);
|
||||
|
||||
@@ -311,7 +311,7 @@ describe('buildTransitReservationParts', () => {
|
||||
],
|
||||
};
|
||||
|
||||
const { endpoints, metadata } = buildTransitReservationParts(namba, kyoto, itinerary);
|
||||
const { endpoints, metadata } = buildTransitReservationParts(namba, kyoto, itinerary, 'transitous');
|
||||
|
||||
expect(endpoints).toEqual([
|
||||
{
|
||||
@@ -407,7 +407,7 @@ describe('buildTransitReservationParts', () => {
|
||||
const kyoto = { name: 'Kyoto', lat: 35.0116, lng: 135.7681 };
|
||||
const itinerary = twoLegs();
|
||||
itinerary.legs = [{ ...itinerary.legs[0], to: stop('Umeda', 34.702, 135.496, null) }, itinerary.legs[1]];
|
||||
const { endpoints, metadata } = buildTransitReservationParts(namba, kyoto, itinerary);
|
||||
const { endpoints, metadata } = buildTransitReservationParts(namba, kyoto, itinerary, 'transitous');
|
||||
expect(endpoints[1]).toEqual({
|
||||
role: 'stop',
|
||||
sequence: 1,
|
||||
@@ -423,14 +423,18 @@ describe('buildTransitReservationParts', () => {
|
||||
});
|
||||
|
||||
it('TRANSIT-ITIN-020: an unresolvable endpoint timezone throws with the coordinates in the message', () => {
|
||||
expect(() => buildTransitReservationParts({ name: 'Nowhere', lat: NaN, lng: 135.501 }, umeda, singleLeg())).toThrow(
|
||||
'Unable to resolve timezone for NaN,135.501.',
|
||||
);
|
||||
expect(() =>
|
||||
buildTransitReservationParts({ name: 'Nowhere', lat: NaN, lng: 135.501 }, umeda, singleLeg(), 'transitous'),
|
||||
).toThrow('Unable to resolve timezone for NaN,135.501.');
|
||||
});
|
||||
|
||||
it('TRANSIT-ITIN-021: an unconvertible itinerary time throws with the ISO string and timezone', () => {
|
||||
expect(() => buildTransitReservationParts(namba, umeda, singleLeg({ startTime: 'not-a-date' }))).toThrow(
|
||||
'Unable to convert not-a-date to local time in Asia/Tokyo.',
|
||||
);
|
||||
expect(() =>
|
||||
buildTransitReservationParts(namba, umeda, singleLeg({ startTime: 'not-a-date' }), 'transitous'),
|
||||
).toThrow('Unable to convert not-a-date to local time in Asia/Tokyo.');
|
||||
});
|
||||
|
||||
it('TRANSIT-ITIN-022: the journey records the backend its itinerary came from (#2850)', () => {
|
||||
expect(buildTransitReservationParts(namba, umeda, singleLeg(), 'google').metadata.transit.provider).toBe('google');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -133,8 +133,8 @@ Transit search is powered by Transitous and uses the existing `geo:read` and `re
|
||||
| Tool | Scope required | Description |
|
||||
|---|---|---|
|
||||
| `search_transit_stops` | `geo:read` | Search real public-transit stops and stations, optionally biased around coordinates. |
|
||||
| `search_transit_routes` | `geo:read` | Search scheduled routes between two coordinates with time, mode, and transfer filters. Also returns `dropped`, the number of provider itineraries that failed validation and are absent from the results. |
|
||||
| `create_transit_journey` | `reservations:write` | Save a selected route as a first-class automated transit journey on a trip day. |
|
||||
| `search_transit_routes` | `geo:read` | Search scheduled routes between two coordinates with time, mode, and transfer filters. Also returns `provider` (`transitous` or `google`, the backend that answered) and `dropped`, the number of provider itineraries that failed validation and are absent from the results. |
|
||||
| `create_transit_journey` | `reservations:write` | Save a selected route as a first-class automated transit journey on a trip day, recording the backend that plans routes for the caller as its provider. |
|
||||
|
||||
### Reservations
|
||||
|
||||
|
||||
Reference in New Issue
Block a user