Tours: tests follow the provenance read and the shared day assignment, the tours addon ends MCP sessions, a line without a point is not copied

The repository stubs of the tour service tests answer findProvenance, which getTour and createTour read since P2C-02.
Switching the Tours addon off now ends live MCP sessions like every other addon that gates a tool (MCP-GATE-PARITY-001).
copyTour refuses a line without a point instead of placing the copy at 0/0, and new tests cover the reachable paths
of the copy, the source check and the provenance of a snapshot without its import record, including a trip copy
keeping that record. On the client the shared day assignment hands the plan exactly the tour and the day, the new
visibility store is reset between tests, and the tests of the sidebar, the detail, the phone sheet and the origin
section match the views this PR built.
This commit is contained in:
Maurice
2026-10-15 22:34:33 +00:00
parent 1d7758b819
commit 52ed63715c
16 changed files with 209 additions and 36 deletions
@@ -155,7 +155,7 @@ describe('TourDetailDialog', () => {
it('uses existing assignment callbacks for add and remove actions', () => {
const { rerender } = render(<TourDetailDialog {...defaultProps} days={[{ id: 7 } as never]} selectedDayId={7} />)
fireEvent.click(screen.getByRole('button', { name: /Add to Day/i }))
expect(defaultProps.onAssignToDay).toHaveBeenCalledWith(42)
expect(defaultProps.onAssignToDay).toHaveBeenCalledWith(42, 7)
rerender(<TourDetailDialog {...defaultProps} days={[{ id: 7 } as never]} selectedDayId={7} assignments={{ '7': [{ id: 9, place }] as never }} />)
fireEvent.click(screen.getByRole('button', { name: /Remove from Day/i }))
@@ -198,14 +198,14 @@ describe('TourDetailDialog', () => {
seedStore(useTripStore, { places: [buildPlace({ id: 7, name: 'Recorded ridge' }), place] })
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, derived_from_place_id: 7 }} />)
expect(screen.getByText('Planned from Recorded ridge')).toBeInTheDocument()
expect(screen.getByText('Based on Recorded ridge')).toBeInTheDocument()
})
it('PR1-05B: says nothing of an origin that is gone from the trip', () => {
seedStore(useTripStore, { places: [place] })
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, derived_from_place_id: 7 }} />)
expect(screen.queryByText(/Planned from/)).not.toBeInTheDocument()
expect(screen.queryByText(/Based on/)).not.toBeInTheDocument()
})
it('shows the grade on the tile and no second difficulty scale', () => {
@@ -111,7 +111,7 @@ describe('ToursSidebar', () => {
expect(within(row).queryByText('TREK')).not.toBeInTheDocument()
expect(screen.getByLabelText(/Maximum trail difficulty|Maximale Wanderschwierigkeit|T2/)).toBeInTheDocument()
const actionRow = row.querySelector('[data-testid="tour-action-row"]')!
expect(actionRow.querySelector('button')).toHaveAttribute('aria-haspopup', 'menu')
expect(within(actionRow as HTMLElement).getByRole('button', { name: /Add to day|Zum Tag hinzufügen/ })).toHaveAttribute('aria-haspopup', 'menu')
expect(container.querySelector('[data-testid="tour-metrics"]')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /Add to day|Zum Tag hinzufügen/ }))
@@ -314,13 +314,20 @@ describe('ToursSidebar', () => {
{ ...tour, place_id: 3, name: 'Snapshot', origin: 'wanderer', max_hiking_difficulty: 3 },
]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Show' }))
const source = screen.getByRole('group', { name: 'Source' })
fireEvent.click(within(source).getByRole('button', { name: /GPX import\s*1/ }))
const limit = () => within(screen.getByRole('group', { name: 'Routing limit' })).getByRole('button', { name: /T3/ })
fireEvent.click(limit())
expect(screen.getAllByRole('option').map(row => row.textContent)).toEqual([expect.stringContaining('Snapshot')])
// Under that limit only the wanderer snapshot is left, so the source offers nothing else.
expect(within(screen.getByRole('group', { name: 'Source' })).queryByRole('button', { name: /GPX import/ })).toBeNull()
fireEvent.click(limit())
fireEvent.click(within(screen.getByRole('group', { name: 'Source' })).getByRole('button', { name: /GPX import\s*1/ }))
expect(screen.getAllByRole('option').map(row => row.textContent)).toEqual([expect.stringContaining('Imported')])
expect(screen.getByRole('button', { name: 'Show' })).toHaveTextContent('+1')
// Only one tour type in the list: no type filter.
expect(screen.queryByRole('group', { name: 'Type' })).toBeNull()
fireEvent.click(within(screen.getByRole('group', { name: 'Routing limit' })).getByRole('button', { name: /T3/ }))
// The import has no routing limit, so none is offered for it; the day status still narrows it away.
expect(screen.queryByRole('group', { name: 'Routing limit' })).toBeNull()
fireEvent.click(within(screen.getByRole('group', { name: 'Status' })).getByRole('button', { name: /^Planned/ }))
expect(screen.getByRole('status')).toHaveTextContent('No tour matches these filters.')
fireEvent.click(screen.getByRole('button', { name: 'Reset filters' }))
expect(screen.getAllByRole('option')).toHaveLength(3)
@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type { TourProvenance, TourSourceCheckResponse } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
import TourProvenanceSection from './TourProvenanceSection';
import type { SourceCheck } from './useSourceCheck';
@@ -28,6 +28,7 @@ function check(over: Partial<SourceCheck> = {}): SourceCheck {
} as SourceCheck;
}
// Rendered without the translation provider: every text reads as its key, so the test pins which string each part shows.
describe('TourProvenanceSection (P2C-02, MF-03)', () => {
it('names a planned tour as planned here, with no local state and no check', () => {
render(<TourProvenanceSection provenance={base} />);
@@ -43,7 +43,7 @@ describe('useTourDayAssignment (PR1-18)', () => {
second = result.current.assign(42, 4);
});
expect(onAssignToDay).toHaveBeenCalledTimes(1);
expect(onAssignToDay).toHaveBeenCalledWith(42, 4, undefined);
expect(onAssignToDay).toHaveBeenCalledWith(42, 4);
expect(await second).toBe(false);
expect(result.current.isPending(42, 4)).toBe(true);
expect(result.current.isAvailable(42, 4)).toBe(false);
@@ -59,10 +59,10 @@ describe('useTourDayAssignment (PR1-18)', () => {
const onAssignToDay = vi.fn(async () => false);
const { result } = renderHook(() => useTourDayAssignment({ canAssign: true, onAssignToDay }));
await act(async () => {
expect(await result.current.assign(42, 4, 2)).toBe(false);
expect(await result.current.assign(42, 4)).toBe(false);
expect(await result.current.assign(42, 3)).toBe(false);
});
expect(onAssignToDay).toHaveBeenCalledTimes(1);
expect(onAssignToDay).toHaveBeenCalledWith(42, 4, 2);
expect(onAssignToDay).toHaveBeenCalledWith(42, 4);
});
});
@@ -45,13 +45,13 @@ export function useTourDayAssignment({ canAssign, onAssignToDay }: TourDayAssign
);
const assign = useCallback(
async (placeId: number, dayId: number, position?: number): Promise<boolean> => {
async (placeId: number, dayId: number): Promise<boolean> => {
const key = `${placeId}:${dayId}`;
if (!isAvailable(placeId, dayId) || inFlight.current.has(key)) return false;
inFlight.current.add(key);
setPending((current) => new Set(current).add(key));
try {
return (await onAssignToDay(placeId, dayId, position)) !== false;
return (await onAssignToDay(placeId, dayId)) !== false;
} finally {
inFlight.current.delete(key);
setPending((current) => {
@@ -15,8 +15,8 @@ vi.mock('../../store/settingsStore', () => ({
selector({ settings: { language: 'en', distance_unit: 'metric' } }),
}))
vi.mock('../../store/tripStore', () => ({
useTripStore: (selector: (state: { loadTrip: () => Promise<void> }) => unknown) =>
selector({ loadTrip: vi.fn() }),
useTripStore: (selector: (state: { loadTrip: () => Promise<void>; places: never[]; assignments: Record<string, never> }) => unknown) =>
selector({ loadTrip: vi.fn(), places: [], assignments: {} }),
}))
const tour: TourListItem = {
@@ -95,16 +95,14 @@ describe('CustomSelect consumer contracts', () => {
onAssignToDay={vi.fn()} onSelectTour={vi.fn()}
/></TranslationProvider>)
expect(screen.getAllByRole('option')).toHaveLength(2)
const filter = screen.getByRole('button', { name: 'Show' })
await user.click(filter)
// The menu stays open while the filters are set, so each pick is one click.
await user.click(screen.getByRole('button', { name: 'Show' }))
await user.click(screen.getByRole('button', { name: /Unplanned\s*1/ }))
expect(screen.getByRole('option', { name: /Ridge walk/ })).toBeInTheDocument()
expect(screen.queryByRole('option', { name: /Planned walk/ })).toBeNull()
await user.click(filter)
await user.click(screen.getByRole('button', { name: /Planned\s*1/ }))
await user.click(screen.getByRole('button', { name: /^Planned\s*1/ }))
expect(screen.getByRole('option', { name: /Planned walk/ })).toBeInTheDocument()
expect(screen.queryByRole('option', { name: /Ridge walk/ })).toBeNull()
await user.click(filter)
await user.click(screen.getByRole('button', { name: /All\s*2/ }))
expect(screen.getAllByRole('option')).toHaveLength(2)
})
+1
View File
@@ -69,6 +69,7 @@ export function buildPlanner(overrides: Partial<TripPlanner> = {}): TripPlanner
reservations: [],
budgetItems: [],
files: [],
tours: [],
selectedDayId: null,
isLoading: false,
+2
View File
@@ -4,6 +4,7 @@ import { useAuthStore } from '../../src/store/authStore';
import { useInAppNotificationStore } from '../../src/store/inAppNotificationStore';
import { usePermissionsStore } from '../../src/store/permissionsStore';
import { useSettingsStore } from '../../src/store/settingsStore';
import { useTourMapVisibilityStore } from '../../src/store/tourMapVisibilityStore';
import { useTripStore } from '../../src/store/tripStore';
import { useVacayStore } from '../../src/store/vacayStore';
@@ -34,6 +35,7 @@ export const RESET_STORES: Record<string, ResettableStore> = {
useAddonStore,
useInAppNotificationStore,
usePermissionsStore,
useTourMapVisibilityStore,
};
/** Not reset so far; moving one into RESET_STORES changes what every file that resets starts from. */
@@ -1,10 +1,11 @@
import { describe, expect, it, vi } from 'vitest';
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell';
import MBrowseActionsSheet from '../../../../src/mobile/screens/trip/sheets/MBrowseActionsSheet';
import { useTripStore } from '../../../../src/store/tripStore';
import type { Place } from '../../../../src/types';
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip';
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render';
import { resetAllStores } from '../../../helpers/store';
import { resetAllStores, seedStore } from '../../../helpers/store';
function tourPlace(): Place {
return {
@@ -51,6 +52,8 @@ function renderDayPicker(planner = mobilePlanner()) {
describe('MBrowseActionsSheet Tour assignment picker', () => {
it('disables assigned Tour days, blocks repeat clicks, and refreshes on reopen', async () => {
resetAllStores();
// The days a tour is on are read from the trip store, the one place every tour view reads them from.
seedStore(useTripStore, { assignments: { '7': [{ id: 70, day_id: 7, place_id: 42 }] } as never });
let finishAssignment!: (assigned: boolean) => void;
const handleAssignToDay = vi.fn(
() =>
@@ -80,12 +83,12 @@ describe('MBrowseActionsSheet Tour assignment picker', () => {
});
await waitFor(() => expect(shell.closeSheet).toHaveBeenCalledOnce());
const reopened = mobilePlanner({
assignments: {
'7': [{ id: 70, day_id: 7, place_id: 42 }],
'8': [{ id: 80, day_id: 8, place_id: 42 }],
} as never,
});
const assignedAfter = {
'7': [{ id: 70, day_id: 7, place_id: 42 }],
'8': [{ id: 80, day_id: 8, place_id: 42 }],
} as never;
act(() => seedStore(useTripStore, { assignments: assignedAfter }));
const reopened = mobilePlanner({ assignments: assignedAfter });
rerender(<MBrowseActionsSheet planner={reopened} shell={shell} />);
expect(screen.getByRole('button', { name: /Day one/ })).toBeDisabled();
expect(screen.getByRole('button', { name: /Day two/ })).toBeDisabled();