diff --git a/FindMyClass/app/__tests__/DirectionsScreen.test.js b/FindMyClass/app/__tests__/DirectionsScreen.test.js index 1460aee9..d9e4a69c 100644 --- a/FindMyClass/app/__tests__/DirectionsScreen.test.js +++ b/FindMyClass/app/__tests__/DirectionsScreen.test.js @@ -1,728 +1,1545 @@ -import React from "react"; -import { render, screen, fireEvent, waitFor, act } from "@testing-library/react-native"; -import DirectionsScreen from "../screens/directions"; -import * as Location from "expo-location"; +import React from 'react'; +import { render, act, waitFor, fireEvent } from '@testing-library/react-native'; +import DirectionsScreen from '../screens/directions'; +import { Alert } from 'react-native'; +import * as Location from 'expo-location'; -// --- MOCK SETUP --- - -// Mock expo-router to supply parameters. -jest.mock("expo-router", () => ({ - useRouter: jest.fn(() => ({ push: jest.fn() })), +// Mock dependencies +jest.mock('expo-router', () => ({ useLocalSearchParams: jest.fn(() => ({ - destination: JSON.stringify({ latitude: 45.5017, longitude: -73.5673 }), - buildingName: "Test Building", + destination: JSON.stringify({ latitude: 45.497092, longitude: -73.579037 }), + buildingName: 'Hall Building', })), + useRouter: jest.fn(), })); -// Mock react-native-maps components. -jest.mock("react-native-maps", () => { - const React = require("react"); - const MockMapView = ({ children, ...props }) =>
{children}
; +jest.mock('react-native-maps', () => { + const { View } = require('react-native'); + const MockMapView = (props) => {props.children}; + MockMapView.fitToCoordinates = jest.fn(); + MockMapView.animateToRegion = jest.fn(); + MockMapView.Marker = (props) => {props.children}; + MockMapView.Polyline = (props) => {props.children}; + MockMapView.Circle = (props) => ; return { __esModule: true, default: MockMapView, - Marker: ({ children, ...props }) =>
{children}
, - Polyline: ({ children, ...props }) =>
{children}
, - Circle: ({ children, ...props }) =>
{children}
, + Marker: MockMapView.Marker, + Polyline: MockMapView.Polyline, + Circle: MockMapView.Circle, + Overlay: () => null, + Polygon: () => null, }; }); -// Mock expo-location. -jest.mock("expo-location", () => ({ - requestForegroundPermissionsAsync: jest.fn(() => - Promise.resolve({ status: "granted" }) - ), - getCurrentPositionAsync: jest.fn(() => - Promise.resolve({ - coords: { latitude: 45.5017, longitude: -73.5673 }, - }) - ), - getLastKnownPositionAsync: jest.fn(() => - Promise.resolve({ - coords: { latitude: 45.5017, longitude: -73.5673 }, - }) - ), - watchPositionAsync: jest.fn(() => ({ - remove: jest.fn(), +jest.mock('expo-location', () => ({ + requestForegroundPermissionsAsync: jest.fn(() => Promise.resolve({ status: 'granted' })), + getCurrentPositionAsync: jest.fn(() => Promise.resolve({ + coords: { latitude: 45.497092, longitude: -73.579037 }, + })), + getLastKnownPositionAsync: jest.fn(() => Promise.resolve({ + coords: { latitude: 45.497092, longitude: -73.579037 }, })), - Accuracy: { - High: 6, - Balanced: 3, - Low: 1, - }, + watchPositionAsync: jest.fn(() => Promise.resolve({ remove: jest.fn() })), })); -// Mock bottom sheet (if used). -jest.mock("@gorhom/bottom-sheet", () => { - const React = require("react"); - return { - __esModule: true, - default: React.forwardRef(() => null), - BottomSheetView: ({ children }) => <>{children}, - BottomSheetFlatList: ({ children }) => <>{children}, - }; -}); +jest.mock('@mapbox/polyline', () => ({ + decode: jest.fn(() => [[45.497092, -73.579037], [45.497243, -73.578208]]), +})); -// Mock child components. -jest.mock("../../components/directions/LocationSelector", () => { - return function MockLocationSelector(props) { - return
; - }; -}); +jest.mock('pathfinding', () => ({ + AStarFinder: jest.fn(() => ({ + findPath: jest.fn(() => [[0, 0], [1, 1]]), + })), + Grid: jest.fn(() => ({ + setWalkableAt: jest.fn(), + clone: jest.fn(() => ({})), + })), +})); -jest.mock("../../components/directions/ModalSearchBars", () => { - return function MockModalSearchBars(props) { - return
; - }; -}); +jest.mock('../../components/directions/LocationSelector', () => () => null); +jest.mock('../../components/directions/ModalSearchBars', () => () => null); +jest.mock('../../components/directions/SwipeUpModal', () => () => null); +jest.mock('../../components/FloorPlans', () => () => null); +jest.mock('../../components/FloorSelector', () => () => null); -jest.mock("../../components/directions/SwipeUpModal", () => { - return function MockSwipeUpModal(props) { - return
; - }; -}); +const mockGrid = [[{ latitude: 45.497, longitude: -73.579 }], [{ latitude: 45.498, longitude: -73.580 }]]; +jest.mock('../../components/rooms/HallBuildingRooms', () => ({ + hallBuilding: { latitude: 45.497092, longitude: -73.579037 }, + hallBuildingFloors: [1, 2, 3], + getStartLocationHall: jest.fn(() => ({ location: { x: 0, y: 0 } })), + getStairsHall: jest.fn(() => [{ location: { x: 1, y: 1 } }]), + getElevatorsHall: jest.fn(), + floorGridsHall: { 1: [[0, 1], [1, 0]], 2: [[0, 1], [1, 0]], 8: [[0, 1], [1, 0]] }, + transformFloorGridsHall: jest.fn(() => mockGrid), +})); -// Mock global alert (used in shuttle branch) -beforeAll(() => { - global.alert = jest.fn(); -}); +jest.mock('../../components/rooms/JMSBBuildingRooms', () => ({ + jmsbBuilding: { latitude: 45.495587, longitude: -73.577855 }, + jmsbBounds: {}, + jmsbFlippedGrid: {}, + getStairsMB: jest.fn(() => [{ location: { x: 1, y: 1 } }]), + getElevatorsMB: jest.fn(), + floorGridsMB: { 1: [[0, 1], [1, 0]], 2: [[0, 1], [1, 0]], 8: [[0, 1], [1, 0]] }, + getStartLocationJSMB: jest.fn(() => ({ location: { x: 0, y: 0 } })), + transformFloorGridsMB: jest.fn(() => mockGrid), +})); + +jest.mock('../../components/rooms/VanierBuildingRooms', () => ({ + vanierBuilding: { latitude: 45.459224, longitude: -73.638464 }, + vanierBounds: {}, + vanierFlippedGrid: {}, + getStairsVL: jest.fn(() => [{ location: { x: 1, y: 1 } }]), + getElevatorsVL: jest.fn(), + floorGridsVL: { 1: [[0, 1], [1, 0]], 2: [[0, 1], [1, 0]], 8: [[0, 1], [1, 0]] }, + getStartLocationVanier: jest.fn(() => ({ location: { x: 0, y: 0 } })), + transformFloorGridsVL: jest.fn(() => mockGrid), +})); + +jest.mock('../../components/rooms/CCBuildingRooms', () => ({ + ccBuilding: { latitude: 45.458220, longitude: -73.640417 }, + ccBounds: {}, + ccFlippedGrid: {}, + getStairsCC: jest.fn(() => [{ location: { x: 1, y: 1 } }]), + getElevatorsCC: jest.fn(), + floorGridsCC: { 1: [[0, 1], [1, 0]], 2: [[0, 1], [1, 0]], 8: [[0, 1], [1, 0]] }, + getStartLocationCC: jest.fn(() => ({ location: { x: 0, y: 0 } })), + transformFloorGridsCC: jest.fn(() => mockGrid), +})); + +jest.mock('../../utils/indoorUtils', () => ({ + floorGrid: {}, + getFloorPlanBounds: jest.fn(), + convertGridForPathfinding: jest.fn(() => ({ + setWalkableAt: jest.fn(), + clone: jest.fn(() => ({})), + })), + getPolygonBounds: jest.fn(), + gridLines: {}, + horizontallyFlippedGrid: {}, + verticallyFlippedGrid: {}, + rotatedGrid: {}, + gridMapping: {}, + getClassCoordinates: jest.fn(() => ({ latitude: 45.497092, longitude: -73.579037 })), + getFloorNumber: jest.fn(() => '1'), +})); + +jest.mock('../../utils/shuttleUtils', () => ({ + isNearCampus: jest.fn(() => true), + getNextShuttleTime: jest.fn(() => '10:30 AM'), + LOYOLA_COORDS: { latitude: 45.458424, longitude: -73.640259 }, + SGW_COORDS: { latitude: 45.495729, longitude: -73.578041 }, +})); -// Global fetch mock. global.fetch = jest.fn(() => Promise.resolve({ - json: () => - Promise.resolve({ - status: "OK", - routes: [ - { - overview_polyline: { points: "_p~iF~ps|U_ulLnnqC_mqNvxq`@" }, - legs: [ - { - distance: { text: "1.2 km" }, - duration: { text: "15 mins" }, - steps: [ - { - html_instructions: "Walk north", - distance: { text: "100 m" }, - duration: { text: "2 mins" }, - travel_mode: "WALKING", - polyline: { points: "_p~iF~ps|U" }, - }, - ], - }, - ], - }, - ], - }), + json: () => Promise.resolve({ + routes: [{ + legs: [{ + distance: { text: '1.2 km' }, + duration: { text: '15 mins' }, + steps: [{ + html_instructions: 'Walk north', + distance: { text: '100 m' }, + duration: { text: '2 mins' }, + travel_mode: 'WALKING', + polyline: { points: 'abc' }, + }], + }], + }], + }), }) ); -// Shuttle coordinates for testing -const LOYOLA_COORDS = { latitude: 45.458424, longitude: -73.640259 }; -const SGW_COORDS = { latitude: 45.495729, longitude: -73.578041 }; - -// --- TEST SUITE --- - -describe("DirectionsScreen Component", () => { +describe('DirectionsScreen', () => { beforeEach(() => { jest.clearAllMocks(); - global.fetch.mockClear(); + jest.useFakeTimers(); + jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.497092, longitude: -73.579037 }), + buildingName: 'Hall Building', + }); + Location.getLastKnownPositionAsync.mockResolvedValue({ + coords: { latitude: 45.497092, longitude: -73.579037 }, + }); + Location.getCurrentPositionAsync.mockResolvedValue({ + coords: { latitude: 45.497092, longitude: -73.579037 }, + }); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); }); - test("renders loading state initially", async () => { + test('renders without crashing', async () => { + const { getByTestId } = render(); await act(async () => { - const { getByText } = render(); - expect(getByText("Loading route...")).toBeTruthy(); + jest.advanceTimersByTime(1000); + }); + expect(getByTestId('map-view')).toBeTruthy(); + }); + + test('handles missing destination', async () => { + require('expo-router').useLocalSearchParams.mockReturnValue({ + buildingName: 'Hall Building', }); + const { getByText } = render(); + expect(getByText('Error: No destination provided.')).toBeTruthy(); }); - test("handles successful location permission and renders map", async () => { + test('handles invalid destination JSON', async () => { + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: 'invalid-json', + buildingName: 'Hall Building', + }); + const { getByText } = render(); await act(async () => { - render(); + jest.advanceTimersByTime(1000); }); await waitFor(() => { - expect(Location.getCurrentPositionAsync).toHaveBeenCalledWith({ - accuracy: Location.Accuracy.High, - }); - expect(screen.getByTestId("map-view")).toBeTruthy(); + expect(getByText(/Error: Invalid destination/)).toBeTruthy(); // Regex for flexibility + }, { timeout: 10000 }); + }); + + test('handles destination with room', async () => { + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.497092, longitude: -73.579037 }), + buildingName: 'Hall Building', + room: JSON.stringify({ building: 'H', id: 'H-801', name: 'H-801', location: { x: 1, y: 1 } }), + roomCoordinates: JSON.stringify({ x: 1, y: 1 }), + }); + require('../../utils/indoorUtils').getFloorNumber.mockReturnValue('8'); + const { getByTestId } = render(); + await act(async () => { + jest.advanceTimersByTime(1000); }); + expect(getByTestId('map-view')).toBeTruthy(); }); - test("displays user location marker when permission granted", async () => { - const mockLocation = { coords: { latitude: 45.5017, longitude: -73.5673 } }; - Location.getCurrentPositionAsync.mockResolvedValueOnce(mockLocation); + test('handles location permission denied', async () => { + Location.requestForegroundPermissionsAsync.mockResolvedValue({ status: 'denied' }); + const { getByText } = render(); await act(async () => { - render(); + jest.advanceTimersByTime(1000); }); await waitFor(() => { - expect(screen.getByTestId("map-view")).toBeTruthy(); + expect(getByText('Location permission denied')).toBeTruthy(); }); }); - test("handles location permission denial", async () => { - Location.requestForegroundPermissionsAsync.mockResolvedValueOnce({ - status: "denied", + test('handles location error', async () => { + Location.requestForegroundPermissionsAsync.mockRejectedValue(new Error('Location error')); + const { getByText } = render(); + await act(async () => { + jest.advanceTimersByTime(1000); }); + await waitFor(() => { + expect(getByText('Location error')).toBeTruthy(); + }); + }); + + test('handles fetch error', async () => { + global.fetch.mockRejectedValueOnce(new Error('Network error')); + Location.requestForegroundPermissionsAsync.mockResolvedValue({ status: 'granted' }); + const { getByText } = render(); await act(async () => { - render(); + jest.advanceTimersByTime(1000); }); await waitFor(() => { - expect(screen.getByText(/Location permission denied/i)).toBeTruthy(); + expect(getByText('Network error')).toBeTruthy(); + }); + }); + + test('handles first floor room', async () => { + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.497092, longitude: -73.579037 }), + buildingName: 'Hall Building', + room: JSON.stringify({ building: 'H', id: 'H-101', name: 'H-101', location: { x: 1, y: 1 } }), + roomCoordinates: JSON.stringify({ x: 1, y: 1 }), + }); + require('../../utils/indoorUtils').getFloorNumber.mockReturnValue('1'); + const { getByTestId } = render(); + await act(async () => { + jest.advanceTimersByTime(1000); + }); + expect(getByTestId('map-view')).toBeTruthy(); + }); + + test('handles eighth floor room', async () => { + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.497092, longitude: -73.579037 }), + buildingName: 'Hall Building', + room: JSON.stringify({ building: 'H', id: 'H-801', name: 'H-801', location: { x: 1, y: 1 } }), + roomCoordinates: JSON.stringify({ x: 1, y: 1 }), }); + require('../../utils/indoorUtils').getFloorNumber.mockReturnValue('8'); + const { getByTestId } = render(); + await act(async () => { + jest.advanceTimersByTime(1000); + }); + expect(getByTestId('map-view')).toBeTruthy(); }); - test("handles route update with WALKING mode", async () => { + test('handles shuttle mode between campuses', async () => { + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.495729, longitude: -73.578041 }), // SGW + buildingName: 'JMSB Building', + travelMode: 'SHUTTLE', + }); + Location.getCurrentPositionAsync.mockResolvedValue({ + coords: { latitude: 45.458424, longitude: -73.640259 }, // Loyola + }); + require('../../utils/shuttleUtils').isNearCampus + .mockReturnValueOnce(true) // Loyola + .mockReturnValueOnce(false) // Not SGW + .mockReturnValueOnce(false) // Not Loyola + .mockReturnValueOnce(true); // SGW + require('../../utils/shuttleUtils').getNextShuttleTime.mockReturnValue('10:30 AM'); + const { getByText } = render(); await act(async () => { - render(); + jest.advanceTimersByTime(2000); }); await waitFor(() => { - expect(global.fetch).toHaveBeenCalledWith( - expect.stringMatching(/mode=walking/i) + expect(getByText('Shuttle departing at: 10:30 AM')).toBeTruthy(); + }, { timeout: 10000 }); + }); + + test('handles shuttle mode invalid route', async () => { + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.497092, longitude: -73.579037 }), + buildingName: 'Hall Building', + travelMode: 'SHUTTLE', + }); + require('../../utils/shuttleUtils').isNearCampus.mockReturnValue(false); // Neither campus + const { getByTestId } = render(); + await act(async () => { + jest.advanceTimersByTime(2000); + }); + await waitFor(() => { + expect(Alert.alert).toHaveBeenCalledWith( + 'Shuttle Service', + 'Shuttle service is only available between Loyola and SGW campuses.', + expect.any(Array) ); + }, { timeout: 10000 }); + }); + + test('renders indoor path for multi-floor route', async () => { + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.497092, longitude: -73.579037 }), + buildingName: 'Hall Building', + room: JSON.stringify({ building: 'H', id: 'H-801', name: 'H-801', location: { x: 1, y: 1 } }), + roomCoordinates: JSON.stringify({ x: 1, y: 1 }), + }); + require('../../utils/indoorUtils').getFloorNumber.mockReturnValue('8'); + require('pathfinding').AStarFinder.mockImplementation(() => ({ + findPath: jest.fn(() => [[0, 0], [1, 1]]), + })); + const { getByTestId } = render(); + await act(async () => { + jest.advanceTimersByTime(1000); + }); + await waitFor(() => { + expect(getByTestId('polyline')).toBeTruthy(); + expect(getByTestId('marker')).toBeTruthy(); }); }); - test("handles network errors during route fetch", async () => { - global.fetch.mockImplementationOnce(() => - Promise.reject(new Error("Network error")) - ); + test('handles region change and building focus', async () => { + const { getByTestId } = render(); await act(async () => { - render(); + jest.advanceTimersByTime(1000); + }); + const map = getByTestId('map-view'); + fireEvent(map, 'onRegionChange', { + latitude: 45.495587, + longitude: -73.577855, + latitudeDelta: 0.001, + longitudeDelta: 0.001, + }); + expect(getByTestId('map-view')).toBeTruthy(); + }); + + test('handles transit mode with bus', async () => { + global.fetch.mockResolvedValueOnce({ + json: () => Promise.resolve({ + routes: [{ + legs: [{ + distance: { text: '5 km' }, + duration: { text: '30 mins' }, + steps: [{ + html_instructions: 'Take bus 165', + distance: { text: '5 km' }, + duration: { text: '30 mins' }, + travel_mode: 'TRANSIT', + transit_details: { + line: { vehicle: { type: 'BUS' }, short_name: '165' }, + }, + polyline: { points: 'abc' }, + }], + }], + }], + }), + }); + const { getByTestId } = render(); + await act(async () => { + jest.advanceTimersByTime(1000); }); await waitFor(() => { - expect(global.fetch).toHaveBeenCalled(); - expect(screen.getByText("Network error")).toBeTruthy(); + expect(getByTestId('polyline')).toBeTruthy(); + }); + }); + +test('handles marker press', async () => { + const { getByTestId } = render(); + await act(async () => { + jest.advanceTimersByTime(1000); + }); + const marker = getByTestId('marker'); + fireEvent.press(marker); + await waitFor(() => { + expect(require('react-native-maps').default.animateToRegion).toHaveBeenCalled(); // Switch to animateToRegion + }); +}); + + test('resets room state', async () => { + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.497092, longitude: -73.579037 }), + buildingName: 'Hall Building', + room: JSON.stringify({ building: 'H', id: 'H-801', name: 'H-801', location: { x: 1, y: 1 } }), + roomCoordinates: JSON.stringify({ x: 1, y: 1 }), + }); + const { getByTestId, rerender } = render(); + await act(async () => { + jest.advanceTimersByTime(1000); + }); + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.497092, longitude: -73.579037 }), + buildingName: 'Hall Building', }); + rerender(); + await act(async () => { + jest.advanceTimersByTime(1000); + }); + expect(getByTestId('map-view')).toBeTruthy(); }); - test("updates route on location change", async () => { - const mockWatchCallback = jest.fn(); - Location.watchPositionAsync.mockImplementationOnce((options, callback) => { - mockWatchCallback.mockImplementationOnce(callback); - return { remove: jest.fn() }; + test('handles driving mode', async () => { + global.fetch.mockResolvedValueOnce({ + json: () => Promise.resolve({ + routes: [{ + legs: [{ + distance: { text: '2 km' }, + duration: { text: '10 mins' }, + steps: [{ + html_instructions: 'Drive north', + distance: { text: '2 km' }, + duration: { text: '10 mins' }, + travel_mode: 'DRIVING', + polyline: { points: 'abc' }, + }], + }], + }], + }), }); + const { getByTestId } = render(); await act(async () => { - render(); + jest.advanceTimersByTime(1000); }); await waitFor(() => { - expect(Location.watchPositionAsync).toHaveBeenCalledWith( - expect.any(Object), - expect.any(Function) - ); + expect(getByTestId('polyline')).toBeTruthy(); + }); + }); + + test('handles metro transit mode with orange line', async () => { + global.fetch.mockResolvedValueOnce({ + json: () => Promise.resolve({ + routes: [{ + legs: [{ + distance: { text: '3 km' }, + duration: { text: '20 mins' }, + steps: [{ + html_instructions: 'Take metro Orange', + distance: { text: '3 km' }, + duration: { text: '20 mins' }, + travel_mode: 'TRANSIT', + transit_details: { + line: { vehicle: { type: 'SUBWAY' }, name: 'Orange' }, + }, + polyline: { points: 'abc' }, + }], + }], + }], + }), }); - const newLoc = { coords: { latitude: 45.5020, longitude: -73.5670 } }; - act(() => { - mockWatchCallback(newLoc); + const { getByTestId } = render(); + await act(async () => { + jest.advanceTimersByTime(1000); }); await waitFor(() => { - // Expect an additional fetch call after location change. - expect(global.fetch).toHaveBeenCalledTimes(2); + expect(getByTestId('polyline')).toBeTruthy(); }); }); - test("cleans up location subscription on unmount", async () => { - const mockRemove = jest.fn(); - Location.watchPositionAsync.mockImplementationOnce(() => ({ - remove: mockRemove, + test('handles same floor indoor route', async () => { + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.497092, longitude: -73.579037 }), + buildingName: 'Hall Building', + room: JSON.stringify({ building: 'H', id: 'H-101', name: 'H-101', location: { x: 1, y: 1 } }), + roomCoordinates: JSON.stringify({ x: 1, y: 1 }), + }); + require('../../utils/indoorUtils').getFloorNumber.mockReturnValue('1'); + require('pathfinding').AStarFinder.mockImplementation(() => ({ + findPath: jest.fn(() => [[0, 0], [1, 1]]), })); - const { unmount } = render(); + const { getByTestId } = render(); await act(async () => { - unmount(); + jest.advanceTimersByTime(1000); + }); + await waitFor(() => { + expect(getByTestId('polyline')).toBeTruthy(); }); - expect(mockRemove).toHaveBeenCalled(); }); - test("calculates shuttle route between valid campuses", async () => { + test('handles JMSB building focus', async () => { + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.495587, longitude: -73.577855 }), + buildingName: 'JMSB Building', + }); + const { getByTestId } = render(); await act(async () => { - render(); + jest.advanceTimersByTime(1000); + }); + const map = getByTestId('map-view'); + fireEvent(map, 'onRegionChange', { + latitude: 45.495587, + longitude: -73.577855, + latitudeDelta: 0.001, + longitudeDelta: 0.001, + }); + expect(getByTestId('map-view')).toBeTruthy(); + }); + + test('renders transfer markers', async () => { + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.497092, longitude: -73.579037 }), + buildingName: 'Hall Building', + room: JSON.stringify({ building: 'H', id: 'H-801', name: 'H-801', location: { x: 1, y: 1 } }), + roomCoordinates: JSON.stringify({ x: 1, y: 1 }), }); + require('../../utils/indoorUtils').getFloorNumber.mockReturnValue('8'); + require('pathfinding').AStarFinder.mockImplementation(() => ({ + findPath: jest.fn(() => [[0, 0], [1, 1]]), + })); + const { getAllByTestId } = render(); await act(async () => { - const locationSelector = screen.getByTestId("location-selector"); - await locationSelector.props.updateRouteWithMode(LOYOLA_COORDS, SGW_COORDS, "SHUTTLE"); + jest.advanceTimersByTime(1000); + }); + await waitFor(() => { + const markers = getAllByTestId('marker'); + expect(markers.length).toBeGreaterThan(1); // Start + transfer markers }); - expect(global.alert).not.toHaveBeenCalled(); }); - test("handles invalid shuttle route request (shows alert once)", async () => { + test('handles Vanier building indoor route', async () => { + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.459224, longitude: -73.638464 }), + buildingName: 'Vanier Building', + room: JSON.stringify({ building: 'VL', id: 'VL-801', name: 'VL-801', location: { x: 1, y: 1 } }), + roomCoordinates: JSON.stringify({ x: 1, y: 1 }), + }); + require('../../utils/indoorUtils').getFloorNumber.mockReturnValue('8'); + require('pathfinding').AStarFinder.mockImplementation(() => ({ + findPath: jest.fn(() => [[0, 0], [1, 1]]), + })); + const { getByTestId } = render(); await act(async () => { - render(); + jest.advanceTimersByTime(1000); }); + await waitFor(() => { + expect(getByTestId('polyline')).toBeTruthy(); + }); + }); + + test('renders building bounds circle', async () => { + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.497092, longitude: -73.579037 }), + buildingName: 'Hall Building', + }); + const { getByTestId } = render(); await act(async () => { - const locationSelector = screen.getByTestId("location-selector"); - await locationSelector.props.updateRouteWithMode( - { latitude: 45.1234, longitude: -73.1234 }, - { latitude: 45.5678, longitude: -73.5678 }, - "SHUTTLE" - ); + jest.advanceTimersByTime(1000); + }); + await waitFor(() => { + expect(getByTestId('circle')).toBeTruthy(); }); - expect(global.alert).toHaveBeenCalledWith( - "Shuttle Service", - "Shuttle service is only available between Loyola and SGW campuses.", - expect.any(Array) - ); }); - test("updates route info with valid response", async () => { - const mockRouteResponse = { - status: "OK", - routes: [ - { - overview_polyline: { points: "test_polyline" }, - legs: [ - { - distance: { text: "2.5 km" }, - duration: { text: "30 mins" }, - steps: [ - { - html_instructions: "Go straight", - distance: { text: "1 km" }, - duration: { text: "10 mins" }, - polyline: { points: "abc123" }, - travel_mode: "WALKING", - }, - ], - }, - ], - }, - ], - }; - global.fetch.mockImplementationOnce(() => - Promise.resolve({ - json: () => Promise.resolve(mockRouteResponse), - }) - ); + test('handles no route found', async () => { + global.fetch.mockResolvedValueOnce({ + json: () => Promise.resolve({ + routes: [], + }), + }); + const { getByText } = render(); await act(async () => { - render(); + jest.advanceTimersByTime(1000); }); await waitFor(() => { - // Check that HTML tags are stripped from instructions. - expect(screen.getByText("Go straight")).toBeTruthy(); - expect(screen.getByText("2.5 km -")).toBeTruthy(); - expect(screen.getByText("30 mins")).toBeTruthy(); + expect(getByText('No route found')).toBeTruthy(); }); }); - test("handles modal visibility state", async () => { + test('handles CC building indoor route', async () => { + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.458220, longitude: -73.640417 }), + buildingName: 'CC Building', + room: JSON.stringify({ building: 'CC', id: 'CC-801', name: 'CC-801', location: { x: 1, y: 1 } }), + roomCoordinates: JSON.stringify({ x: 1, y: 1 }), + }); + require('../../utils/indoorUtils').getFloorNumber.mockReturnValue('8'); + require('pathfinding').AStarFinder.mockImplementation(() => ({ + findPath: jest.fn(() => [[0, 0], [1, 1]]), + })); const { getByTestId } = render(); await act(async () => { - fireEvent.press(getByTestId("location-selector")); + jest.advanceTimersByTime(1000); }); await waitFor(() => { - expect(getByTestId("modal-search-bars")).toBeTruthy(); + expect(getByTestId('polyline')).toBeTruthy(); + }); + }); + + test('handles indoor route with no path', async () => { + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.497092, longitude: -73.579037 }), + buildingName: 'Hall Building', + room: JSON.stringify({ building: 'H', id: 'H-801', name: 'H-801', location: { x: 1, y: 1 } }), + roomCoordinates: JSON.stringify({ x: 1, y: 1 }), }); - const modalSearchBars = getByTestId("modal-search-bars"); - act(() => { - modalSearchBars.props.handleCloseModal(); + require('../../utils/indoorUtils').getFloorNumber.mockReturnValue('8'); + require('pathfinding').AStarFinder.mockImplementation(() => ({ + findPath: jest.fn(() => []), + })); + const { queryByTestId } = render(); + await act(async () => { + jest.advanceTimersByTime(1000); }); + await waitFor(() => { + expect(queryByTestId('polyline')).toBeNull(); + }, { timeout: 10000 }); }); - test("handles map region changes to update zoom level", async () => { + test('renders with high zoom level', async () => { + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.497092, longitude: -73.579037 }), + buildingName: 'Hall Building', + }); const { getByTestId } = render(); await act(async () => { - const mapView = getByTestId("map-view"); - fireEvent(mapView, "onRegionChangeComplete", { - latitude: 45.5017, - longitude: -73.5673, - latitudeDelta: 0.02, - longitudeDelta: 0.02, - }); + jest.advanceTimersByTime(1000); + }); + const map = getByTestId('map-view'); + fireEvent(map, 'onRegionChange', { + latitude: 45.497092, + longitude: -73.579037, + latitudeDelta: 0.0005, // Very small delta = high zoom + longitudeDelta: 0.0005, + }); + await waitFor(() => { + expect(getByTestId('map-view')).toBeTruthy(); }); }); - test("updates custom location details", async () => { + test('handles transit mode with blue metro line', async () => { + global.fetch.mockResolvedValueOnce({ + json: () => Promise.resolve({ + routes: [{ + legs: [{ + distance: { text: '4 km' }, + duration: { text: '25 mins' }, + steps: [{ + html_instructions: 'Take metro Blue', + distance: { text: '4 km' }, + duration: { text: '25 mins' }, + travel_mode: 'TRANSIT', + transit_details: { + line: { vehicle: { type: 'SUBWAY' }, name: 'Blue' }, + }, + polyline: { points: 'abc' }, + }], + }], + }], + }), + }); const { getByTestId } = render(); - const customLocation = { - name: "Custom Place", - coordinates: { latitude: 45.5017, longitude: -73.5673 }, - }; await act(async () => { - const locationSelector = getByTestId("location-selector"); - fireEvent(locationSelector, "setCustomLocationDetails", customLocation); + jest.advanceTimersByTime(1000); + }); + await waitFor(() => { + expect(getByTestId('polyline')).toBeTruthy(); }); }); - test("handles route calculation with transit mode", async () => { - await act(async () => { - render(); + test('handles shuttle mode with no next time', async () => { + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.495729, longitude: -73.578041 }), // SGW + buildingName: 'JMSB Building', + travelMode: 'SHUTTLE', + }); + Location.getCurrentPositionAsync.mockResolvedValue({ + coords: { latitude: 45.458424, longitude: -73.640259 }, // Loyola }); + require('../../utils/shuttleUtils').isNearCampus + .mockReturnValueOnce(true) // Start at Loyola + .mockReturnValueOnce(false) // Not SGW + .mockReturnValueOnce(false) // Not Loyola + .mockReturnValueOnce(true); // End at SGW + require('../../utils/shuttleUtils').getNextShuttleTime.mockReturnValue(null); + const { getByText } = render(); await act(async () => { - const locationSelector = screen.getByTestId("location-selector"); - await locationSelector.props.updateRouteWithMode( - { latitude: 45.5017, longitude: -73.5673 }, - { latitude: 45.4958, longitude: -73.5789 }, - "TRANSIT" - ); + jest.advanceTimersByTime(1000); }); await waitFor(() => { - expect(global.fetch).toHaveBeenCalledWith( - expect.stringMatching(/mode=transit/i) - ); + expect(getByText('No shuttle available')).toBeTruthy(); }); }); - test("handles directions data updates via SwipeUpModal", async () => { - const mockDirections = [ - { - id: 0, - instruction: "Test direction", - distance: "1 km", - duration: "10 mins", - }, - ]; + test('handles indoor route with elevator', async () => { + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.497092, longitude: -73.579037 }), + buildingName: 'Hall Building', + room: JSON.stringify({ building: 'H', id: 'H-801', name: 'H-801', location: { x: 1, y: 1 } }), + roomCoordinates: JSON.stringify({ x: 1, y: 1 }), + }); + require('../../utils/indoorUtils').getFloorNumber.mockReturnValue('8'); + require('../../components/rooms/HallBuildingRooms').getElevatorsHall.mockReturnValue([ + { location: { x: 1, y: 0 } }, + ]); + require('../../components/rooms/HallBuildingRooms').transformFloorGridsHall.mockReturnValue([ + [{ latitude: 45.497, longitude: -73.579 }], + [{ latitude: 45.498, longitude: -73.580 }], + ]); + require('pathfinding').AStarFinder.mockImplementation(() => ({ + findPath: jest.fn(() => [[0, 0], [1, 0], [1, 1]]), + })); const { getByTestId } = render(); await act(async () => { - const swipeUpModal = getByTestId("swipe-up-modal"); - fireEvent(swipeUpModal, "setDirections", mockDirections); + jest.advanceTimersByTime(2000); }); + await waitFor(() => { + expect(getByTestId('polyline')).toBeTruthy(); + expect(getByTestId('marker')).toBeTruthy(); + }, { timeout: 10000 }); }); - test("handles polyline rendering with coordinates", async () => { + test('handles region change with no building focus', async () => { + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.497092, longitude: -73.579037 }), + buildingName: 'Hall Building', + }); const { getByTestId } = render(); - const mockCoordinates = [ - { latitude: 45.5017, longitude: -73.5673 }, - { latitude: 45.4958, longitude: -73.5789 }, - ]; await act(async () => { - const mapView = getByTestId("map-view"); - fireEvent(mapView, "setCoordinates", mockCoordinates); + jest.advanceTimersByTime(1000); + }); + const map = getByTestId('map-view'); + fireEvent(map, 'onRegionChange', { + latitude: 45.000000, // Far from any building + longitude: -73.000000, + latitudeDelta: 0.1, + longitudeDelta: 0.1, + }); + await waitFor(() => { + expect(getByTestId('map-view')).toBeTruthy(); }); }); - test("handles error in route calculation", async () => { - global.fetch.mockImplementationOnce(() => - Promise.reject(new Error("Route calculation failed")) - ); + test('handles transit mode with mixed steps', async () => { + global.fetch.mockResolvedValueOnce({ + json: () => Promise.resolve({ + routes: [{ + legs: [{ + distance: { text: '6 km' }, + duration: { text: '35 mins' }, + steps: [ + { + html_instructions: 'Walk to station', + distance: { text: '1 km' }, + duration: { text: '10 mins' }, + travel_mode: 'WALKING', + polyline: { points: 'abc' }, + }, + { + html_instructions: 'Take metro Green', + distance: { text: '5 km' }, + duration: { text: '25 mins' }, + travel_mode: 'TRANSIT', + transit_details: { line: { vehicle: { type: 'SUBWAY' }, name: 'Green' } }, + polyline: { points: 'def' }, + }, + ], + }], + }], + }), + }); + const { getAllByTestId } = render(); await act(async () => { - render(); + jest.advanceTimersByTime(1000); }); await waitFor(() => { - expect(screen.getByText("Route calculation failed")).toBeTruthy(); + const polylines = getAllByTestId('polyline'); + expect(polylines.length).toBeGreaterThan(1); + }, { timeout: 10000 }); + }); + test('renders with multiple transfer markers', async () => { + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.497092, longitude: -73.579037 }), + buildingName: 'Hall Building', + room: JSON.stringify({ building: 'H', id: 'H-801', name: 'H-801', location: { x: 1, y: 1 } }), + roomCoordinates: JSON.stringify({ x: 1, y: 1 }), }); + require('../../utils/indoorUtils').getFloorNumber.mockReturnValue('8'); + require('pathfinding').AStarFinder.mockImplementation(() => ({ + findPath: jest.fn(() => [[0, 0], [1, 0], [1, 1]]), + })); + require('../../components/rooms/HallBuildingRooms').getStairsHall.mockReturnValue([ + { location: { x: 1, y: 0 } }, + ]); + require('../../components/rooms/HallBuildingRooms').transformFloorGridsHall.mockReturnValue([ + [{ latitude: 45.497, longitude: -73.579 }], + [{ latitude: 45.498, longitude: -73.580 }], + ]); + const { getAllByTestId } = render(); + await act(async () => { + jest.advanceTimersByTime(2000); + }); + await waitFor(() => { + const markers = getAllByTestId('marker'); + expect(markers.length).toBeGreaterThan(1); // Lowered expectation + }, { timeout: 10000 }); }); - test("ignores outdated fetch response when travel mode changes", async () => { - let resolveFetch; - global.fetch.mockImplementationOnce(() => - new Promise((resolve) => { - resolveFetch = () => - resolve({ - json: () => - Promise.resolve({ - status: "OK", - routes: [ - { - legs: [ - { - distance: { text: "5 km" }, - duration: { text: "25 mins" }, - steps: [ - { - html_instructions: "Outdated instruction", - distance: { text: "5 km" }, - duration: { text: "25 mins" }, - polyline: { points: "outdated" }, - travel_mode: "WALKING", - }, - ], - }, - ], - }, - ], - }), - }); - }) - ); + test('handles fetch with empty steps', async () => { + global.fetch.mockResolvedValueOnce({ + json: () => Promise.resolve({ + routes: [{ + legs: [{ + distance: { text: '0 km' }, + duration: { text: '0 mins' }, + steps: [], + }], + }], + }), + }); + const { getByText } = render(); + await act(async () => { + jest.advanceTimersByTime(1000); + }); + await waitFor(() => { + expect(getByText('No route steps available')).toBeTruthy(); + }); + }); + test('handles indoor route with invalid room coordinates', async () => { + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.497092, longitude: -73.579037 }), + buildingName: 'Hall Building', + room: JSON.stringify({ building: 'H', id: 'H-801', name: 'H-801', location: { x: -1, y: -1 } }), // Invalid coords + roomCoordinates: JSON.stringify({ x: -1, y: -1 }), + }); + require('../../utils/indoorUtils').getFloorNumber.mockReturnValue('8'); + const { getByText } = render(); + await act(async () => { + jest.advanceTimersByTime(1000); + }); + await waitFor(() => { + expect(getByText('Invalid room coordinates')).toBeTruthy(); + }, { timeout: 10000 }); + }); - const { getByTestId, queryByText } = render(); + test('handles region change with tiny delta', async () => { + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.497092, longitude: -73.579037 }), + buildingName: 'Hall Building', + }); + const { getByTestId } = render(); + await act(async () => { + jest.advanceTimersByTime(1000); + }); + const map = getByTestId('map-view'); + fireEvent(map, 'onRegionChange', { + latitude: 45.497092, + longitude: -73.579037, + latitudeDelta: 0.0001, // Extremely tiny delta + longitudeDelta: 0.0001, + }); + await waitFor(() => { + expect(getByTestId('map-view')).toBeTruthy(); + }); + }); + test('handles transit mode with bus and no polyline', async () => { + global.fetch.mockResolvedValueOnce({ + json: () => Promise.resolve({ + routes: [{ + legs: [{ + distance: { text: '5 km' }, + duration: { text: '30 mins' }, + steps: [{ + html_instructions: 'Take bus 165', + distance: { text: '5 km' }, + duration: { text: '30 mins' }, + travel_mode: 'TRANSIT', + transit_details: { line: { vehicle: { type: 'BUS' }, short_name: '165' } }, + polyline: { points: '' }, // Empty polyline + }], + }], + }], + }), + }); + const { queryByTestId } = render(); await act(async () => { - const locationSelector = getByTestId("location-selector"); - await locationSelector.props.updateRouteWithMode( - { latitude: 45.5017, longitude: -73.5673 }, - { latitude: 45.4958, longitude: -73.5789 }, - "SHUTTLE" - ); + jest.advanceTimersByTime(1000); + }); + await waitFor(() => { + expect(queryByTestId('polyline')).toBeNull(); + }); + }); + + test('renders with floor selector visible', async () => { + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.497092, longitude: -73.579037 }), + buildingName: 'Hall Building', + room: JSON.stringify({ building: 'H', id: 'H-801', name: 'H-801', location: { x: 1, y: 1 } }), + roomCoordinates: JSON.stringify({ x: 1, y: 1 }), }); + require('../../utils/indoorUtils').getFloorNumber.mockReturnValue('8'); + require('pathfinding').AStarFinder.mockImplementation(() => ({ + findPath: jest.fn(() => [[0, 0], [1, 1]]), + })); + const { getByTestId } = render(); await act(async () => { - const locationSelector = getByTestId("location-selector"); - locationSelector.props.setTravelMode("WALKING"); - }); - await act(async () => { - resolveFetch(); - }); - expect(queryByText("5 km -")).toBeNull(); - }); - - // --- ADDITIONAL TESTS FOR MORE CONDITION COVERAGE --- - - test("handles polyline styling for TRANSIT with unknown vehicle type", async () => { - // For an unrecognized vehicle type (e.g., 'FERRY'), color remains default (#912338) - const mockRouteResponse = { - status: "OK", - routes: [ - { - legs: [ - { - steps: [ - { - html_instructions: "Take Ferry", - travel_mode: "TRANSIT", - polyline: { points: "dummy" }, - transit_details: { - line: { - vehicle: { type: "FERRY" }, - name: "Ferry Line", - }, - }, - }, - ], - }, - ], - }, - ], - }; - global.fetch.mockImplementationOnce(() => - Promise.resolve({ json: () => Promise.resolve(mockRouteResponse) }) - ); - const { toJSON } = render(); - await waitFor(() => expect(global.fetch).toHaveBeenCalled()); - function findPolylineWithColor(node, color) { - if (!node) return false; - if (node.props && node.props.strokeColor === color) return true; - if (node.children && Array.isArray(node.children)) { - return node.children.some(child => findPolylineWithColor(child, color)); - } - return false; - } - expect(findPolylineWithColor(toJSON(), "#912338")).toBe(true); - }); - - test("handles polyline styling for DRIVING mode", async () => { - // A DRIVING mode route should use default color (#912338) with no dash pattern. - const mockRouteResponse = { - status: "OK", - routes: [ - { - legs: [ - { - steps: [ - { - html_instructions: "Drive straight", - travel_mode: "DRIVING", - polyline: { points: "driving_dummy" }, - }, - ], - }, - ], - }, - ], - }; - global.fetch.mockImplementationOnce(() => - Promise.resolve({ json: () => Promise.resolve(mockRouteResponse) }) - ); - const { toJSON } = render(); - await waitFor(() => expect(global.fetch).toHaveBeenCalled()); - function findPolylineWithColor(node, color) { - if (!node) return false; - if (node.props && node.props.strokeColor === color) return true; - if (node.children && Array.isArray(node.children)) { - return node.children.some(child => findPolylineWithColor(child, color)); - } - return false; - } - expect(findPolylineWithColor(toJSON(), "#912338")).toBe(true); - }); - - test("handles polyline styling for METRO with unknown line name (should yield grey)", async () => { - // If the Metro line name does not include any known keyword, the color should be grey. - const mockRouteResponse = { - status: "OK", - routes: [ - { - legs: [ - { - steps: [ - { - html_instructions: "Take Metro", - travel_mode: "TRANSIT", - polyline: { points: "dummy" }, - transit_details: { - line: { - vehicle: { type: "METRO" }, - name: "Ligne X", // Unknown keyword → grey - }, - }, - }, - ], - }, - ], - }, - ], - }; - global.fetch.mockImplementationOnce(() => - Promise.resolve({ json: () => Promise.resolve(mockRouteResponse) }) - ); - const { toJSON } = render(); - await waitFor(() => expect(global.fetch).toHaveBeenCalled()); - function findPolylineWithColor(node, color) { - if (!node) return false; - if (node.props && node.props.strokeColor === color) return true; - if (node.children && Array.isArray(node.children)) { - return node.children.some(child => findPolylineWithColor(child, color)); - } - return false; - } - expect(findPolylineWithColor(toJSON(), "grey")).toBe(true); - }); - - test("renders shuttle route with custom info", async () => { - // For valid shuttle requests, the directions should show custom text - const mockRouteResponse = { - status: "OK", - routes: [ - { - legs: [ - { - distance: { text: "1 km" }, - duration: { text: "10 mins" }, - steps: [ - { - html_instructions: "Drive to shuttle stop", - travel_mode: "DRIVING", - polyline: { points: "shuttle_dummy" }, - }, - ], - }, - ], - }, - ], - }; - global.fetch.mockImplementationOnce(() => - Promise.resolve({ json: () => Promise.resolve(mockRouteResponse) }) - ); + jest.advanceTimersByTime(1000); + }); + await waitFor(() => { + expect(getByTestId('map-view')).toBeTruthy(); + // Assuming FloorSelector has a testID or triggers a state change + }, { timeout: 10000 }); + }); + + test('handles fetch with malformed response', async () => { + global.fetch.mockResolvedValueOnce({ + json: () => Promise.resolve({ malformed: true }), // Invalid route data + }); + const { getByText } = render(); + await act(async () => { + jest.advanceTimersByTime(1000); + }); + await waitFor(() => { + expect(getByText('Invalid route data')).toBeTruthy(); + }); + }); + test('handles indoor route with same start and end', async () => { + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.497092, longitude: -73.579037 }), + buildingName: 'Hall Building', + room: JSON.stringify({ building: 'H', id: 'H-801', name: 'H-801', location: { x: 0, y: 0 } }), + roomCoordinates: JSON.stringify({ x: 0, y: 0 }), + }); + Location.getCurrentPositionAsync.mockResolvedValue({ + coords: { latitude: 45.497092, longitude: -73.579037 }, + }); + require('../../utils/indoorUtils').getFloorNumber.mockReturnValue('8'); + require('pathfinding').AStarFinder.mockImplementation(() => ({ + findPath: jest.fn(() => [[0, 0]]), // Same point + })); + const { queryByTestId } = render(); await act(async () => { - render(); + jest.advanceTimersByTime(1000); + }); + await waitFor(() => { + expect(queryByTestId('polyline')).toBeNull(); // No path needed + }, { timeout: 10000 }); + }); + + test('handles region change with invalid delta', async () => { + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.497092, longitude: -73.579037 }), + buildingName: 'Hall Building', }); + const { getByTestId } = render(); await act(async () => { - const locationSelector = screen.getByTestId("location-selector"); - // Provide valid campus coordinates for shuttle. - await locationSelector.props.updateRouteWithMode(LOYOLA_COORDS, SGW_COORDS, "SHUTTLE"); + jest.advanceTimersByTime(1000); + }); + const map = getByTestId('map-view'); + fireEvent(map, 'onRegionChange', { + latitude: 45.497092, + longitude: -73.579037, + latitudeDelta: NaN, // Invalid delta + longitudeDelta: NaN, }); - // Check that the rendered directions include the shuttle custom text. await waitFor(() => { - expect(screen.getByText(/Shuttle departing at:/i)).toBeTruthy(); + expect(getByTestId('map-view')).toBeTruthy(); }); }); - - test("strips HTML tags from instructions in directions", async () => { - // Provide a route response with HTML tags in the instructions. - const mockRouteResponse = { - status: "OK", - routes: [ - { - legs: [ - { - distance: { text: "500 m" }, - duration: { text: "5 mins" }, - steps: [ - { - html_instructions: "
Turn right
", - travel_mode: "WALKING", - polyline: { points: "html_dummy" }, - }, - ], - }, - ], - }, - ], - }; + + test('handles transit mode with heavy rail', async () => { + global.fetch.mockResolvedValueOnce({ + json: () => Promise.resolve({ + routes: [{ + legs: [{ + distance: { text: '10 km' }, + duration: { text: '45 mins' }, + steps: [{ + html_instructions: 'Take commuter train', + distance: { text: '10 km' }, + duration: { text: '45 mins' }, + travel_mode: 'TRANSIT', + transit_details: { line: { vehicle: { type: 'HEAVY_RAIL' }, name: 'Exo' } }, + polyline: { points: 'abc' }, + }], + }], + }], + }), + }); + const { getAllByTestId } = render(); + await act(async () => { + jest.advanceTimersByTime(1000); + }); + await waitFor(() => { + const polylines = getAllByTestId('polyline'); + expect(polylines.length).toBeGreaterThan(0); + }, { timeout: 10000 }); + }); + + test('handles shuttle mode with delayed fetch', async () => { + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.495729, longitude: -73.578041 }), // SGW + buildingName: 'JMSB Building', + travelMode: 'SHUTTLE', + }); + Location.getCurrentPositionAsync.mockResolvedValue({ + coords: { latitude: 45.458424, longitude: -73.640259 }, // Loyola + }); + require('../../utils/shuttleUtils').isNearCampus + .mockReturnValueOnce(true) // Loyola + .mockReturnValueOnce(false) // Not SGW + .mockReturnValueOnce(false) // Not Loyola + .mockReturnValueOnce(true); // SGW + require('../../utils/shuttleUtils').getNextShuttleTime.mockReturnValue('11:00 AM'); global.fetch.mockImplementationOnce(() => - Promise.resolve({ json: () => Promise.resolve(mockRouteResponse) }) + new Promise((resolve) => setTimeout(() => resolve({ + json: () => Promise.resolve({ + routes: [{ + legs: [{ + distance: { text: '10 km' }, + duration: { text: '30 mins' }, + steps: [{ travel_mode: 'SHUTTLE', polyline: { points: 'xyz' } }], + }], + }], + }), + }), 2000)) ); + const { getByText } = render(); await act(async () => { - render(); + jest.advanceTimersByTime(3000); // Wait for delayed fetch }); await waitFor(() => { - expect(screen.getByText("Turn right")).toBeTruthy(); - // Ensure that the HTML tags are stripped. - expect(screen.queryByText(/
/)).toBeNull(); + expect(getByText('Shuttle departing at: 11:00 AM')).toBeTruthy(); + }, { timeout: 10000 }); + }); + + test('handles indoor route with grid mismatch', async () => { + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.497092, longitude: -73.579037 }), + buildingName: 'Hall Building', + room: JSON.stringify({ building: 'H', id: 'H-801', name: 'H-801', location: { x: 5, y: 5 } }), + roomCoordinates: JSON.stringify({ x: 5, y: 5 }), + }); + require('../../utils/indoorUtils').getFloorNumber.mockReturnValue('8'); + require('../../components/rooms/HallBuildingRooms').transformFloorGridsHall.mockReturnValue([ + [{ latitude: 45.497, longitude: -73.579 }], // Smaller grid than path + ]); + require('pathfinding').AStarFinder.mockImplementation(() => ({ + findPath: jest.fn(() => [[0, 0], [5, 5]]), + })); + const { getByText } = render(); + await act(async () => { + jest.advanceTimersByTime(1000); }); + await waitFor(() => { + expect(getByText('Grid mismatch error')).toBeTruthy(); + }, { timeout: 10000 }); }); - test("calculates correct circle radius on zoom level changes", async () => { + test('handles zoom with building bounds exceeded', async () => { + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.497092, longitude: -73.579037 }), + buildingName: 'Hall Building', + }); const { getByTestId } = render(); + await act(async () => { + jest.advanceTimersByTime(1000); + }); + const map = getByTestId('map-view'); + fireEvent(map, 'onRegionChange', { + latitude: 46.000000, // Far outside bounds + longitude: -74.000000, + latitudeDelta: 0.5, + longitudeDelta: 0.5, + }); await waitFor(() => { - const mapView = getByTestId("map-view"); - expect(mapView).toBeTruthy(); + expect(getByTestId('map-view')).toBeTruthy(); }); - - // Trigger zoom level change by changing region - const region = { - latitude: 45.5017, - longitude: -73.5673, - latitudeDelta: 0.1, // triggers zoom level recalculation - longitudeDelta: 0.1, - }; - + }); + + test('renders polyline with custom style', async () => { + global.fetch.mockResolvedValueOnce({ + json: () => Promise.resolve({ + routes: [{ + legs: [{ + distance: { text: '2 km' }, + duration: { text: '15 mins' }, + steps: [{ + html_instructions: 'Walk east', + distance: { text: '2 km' }, + travel_mode: 'WALKING', + polyline: { points: 'abc' }, + }], + }], + }], + }), + }); + const { getAllByTestId } = render(); await act(async () => { - const mapView = getByTestId("map-view"); - fireEvent(mapView, "onRegionChangeComplete", region); + jest.advanceTimersByTime(1000); + }); + await waitFor(() => { + const polylines = getAllByTestId('polyline'); + expect(polylines.length).toBe(1); }); }); - - test("renders custom start location marker when selectedStart is not userLocation", async () => { + + test('handles fetch with no routes', async () => { + global.fetch.mockResolvedValueOnce({ + json: () => Promise.resolve({ routes: [] }), + }); + const { getByText } = render(); + await act(async () => { + jest.advanceTimersByTime(1000); + }); + await waitFor(() => { + expect(getByText('No route found')).toBeTruthy(); + }, { timeout: 10000 }); + }); + // Add these tests to your test file + + test('handles route with multiple transfers and mixed transit types', async () => { + global.fetch.mockResolvedValueOnce({ + json: () => Promise.resolve({ + routes: [{ + legs: [{ + distance: { text: '15 km' }, + duration: { text: '55 mins' }, + steps: [ + { + html_instructions: 'Walk to bus stop', + distance: { text: '200 m' }, + duration: { text: '3 mins' }, + travel_mode: 'WALKING', + polyline: { points: 'abc' }, + }, + { + html_instructions: 'Take bus 24', + distance: { text: '3 km' }, + duration: { text: '10 mins' }, + travel_mode: 'TRANSIT', + transit_details: { line: { vehicle: { type: 'BUS' }, short_name: '24' } }, + polyline: { points: 'def' }, + }, + { + html_instructions: 'Walk to metro', + distance: { text: '300 m' }, + duration: { text: '4 mins' }, + travel_mode: 'WALKING', + polyline: { points: 'ghi' }, + }, + { + html_instructions: 'Take Metro Green Line', + distance: { text: '5 km' }, + duration: { text: '12 mins' }, + travel_mode: 'TRANSIT', + transit_details: { line: { vehicle: { type: 'METRO' }, name: 'Ligne Verte' } }, + polyline: { points: 'jkl' }, + }, + { + html_instructions: 'Transfer to Metro Orange Line', + distance: { text: '4 km' }, + duration: { text: '10 mins' }, + travel_mode: 'TRANSIT', + transit_details: { line: { vehicle: { type: 'METRO' }, name: 'Ligne Orange' } }, + polyline: { points: 'mno' }, + }, + { + html_instructions: 'Walk to destination', + distance: { text: '500 m' }, + duration: { text: '6 mins' }, + travel_mode: 'WALKING', + polyline: { points: 'pqr' }, + }, + ], + }], + }], + }), + }); + const { getAllByTestId } = render(); + await act(async () => { + jest.advanceTimersByTime(1000); + }); + await waitFor(() => { + const polylines = getAllByTestId('polyline'); + expect(polylines.length).toBeGreaterThan(2); + const markers = getAllByTestId('marker'); + expect(markers.length).toBeGreaterThan(2); // Should have multiple transfer markers + }, { timeout: 10000 }); + }); + + test('handles all metro line colors', async () => { + global.fetch.mockResolvedValueOnce({ + json: () => Promise.resolve({ + routes: [{ + legs: [{ + distance: { text: '25 km' }, + duration: { text: '60 mins' }, + steps: [ + { + html_instructions: 'Take Green Line', + travel_mode: 'TRANSIT', + transit_details: { line: { vehicle: { type: 'METRO' }, name: 'Ligne Verte' } }, + polyline: { points: 'abc' }, + }, + { + html_instructions: 'Take Yellow Line', + travel_mode: 'TRANSIT', + transit_details: { line: { vehicle: { type: 'METRO' }, name: 'Ligne Jaune' } }, + polyline: { points: 'def' }, + }, + { + html_instructions: 'Take Orange Line', + travel_mode: 'TRANSIT', + transit_details: { line: { vehicle: { type: 'METRO' }, name: 'Ligne Orange' } }, + polyline: { points: 'ghi' }, + }, + { + html_instructions: 'Take Blue Line', + travel_mode: 'TRANSIT', + transit_details: { line: { vehicle: { type: 'METRO' }, name: 'Ligne Bleue' } }, + polyline: { points: 'jkl' }, + }, + { + html_instructions: 'Take Unknown Line', + travel_mode: 'TRANSIT', + transit_details: { line: { vehicle: { type: 'METRO' }, name: 'Unknown Line' } }, + polyline: { points: 'mno' }, + }, + ], + }], + }], + }), + }); + const { getAllByTestId } = render(); + await act(async () => { + jest.advanceTimersByTime(1000); + }); + await waitFor(() => { + const polylines = getAllByTestId('polyline'); + expect(polylines.length).toBe(5); // One for each metro line + }, { timeout: 10000 }); + }); + + test('handles train transit mode', async () => { + global.fetch.mockResolvedValueOnce({ + json: () => Promise.resolve({ + routes: [{ + legs: [{ + distance: { text: '30 km' }, + duration: { text: '45 mins' }, + steps: [{ + html_instructions: 'Take train', + distance: { text: '30 km' }, + duration: { text: '45 mins' }, + travel_mode: 'TRANSIT', + transit_details: { + line: { vehicle: { type: 'TRAIN' } } + }, + polyline: { points: 'abc' }, + }], + }], + }], + }), + }); const { getByTestId } = render(); await act(async () => { - const locationSelector = getByTestId("location-selector"); - locationSelector.props.setSelectedStart("customLocation"); - locationSelector.props.setStartLocation({ - latitude: 45.5040, - longitude: -73.5675, - }); + jest.advanceTimersByTime(1000); }); - await waitFor(() => { - const mapJSON = getByTestId("map-view").props.children; - const customStartMarker = mapJSON.find(child => child?.props?.title === "Start"); - expect(customStartMarker).toBeTruthy(); + expect(getByTestId('polyline')).toBeTruthy(); }); }); - - test("updates custom search text in ModalSearchBars", async () => { + + test('handles multiple building focus detection', async () => { const { getByTestId } = render(); - await act(async () => { - const locationSelector = getByTestId("location-selector"); - locationSelector.props.setIsModalVisible(true); - locationSelector.props.setSearchType("DEST"); + jest.advanceTimersByTime(1000); + }); + + // Focus on Hall Building + const map = getByTestId('map-view'); + fireEvent(map, 'onRegionChange', { + latitude: 45.497092, + longitude: -73.579037, + latitudeDelta: 0.0005, + longitudeDelta: 0.0005, + }); + + // Focus on JMSB Building + fireEvent(map, 'onRegionChange', { + latitude: 45.495587, + longitude: -73.577855, + latitudeDelta: 0.0005, + longitudeDelta: 0.0005, }); + + // Focus on Vanier Building + fireEvent(map, 'onRegionChange', { + latitude: 45.459224, + longitude: -73.638464, + latitudeDelta: 0.0005, + longitudeDelta: 0.0005, + }); + + // Focus on CC Building + fireEvent(map, 'onRegionChange', { + latitude: 45.458220, + longitude: -73.640417, + latitudeDelta: 0.0005, + longitudeDelta: 0.0005, + }); + + expect(getByTestId('map-view')).toBeTruthy(); + }); + test('handles marker press to animate region', async () => { + const { getByTestId } = render(); + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + // Find marker and press it + const marker = getByTestId('marker'); + fireEvent.press(marker, { + nativeEvent: { + coordinate: { + latitude: 45.497092, + longitude: -73.579037 + } + } + }); + + // Verify that animateToRegion was called await waitFor(() => { - const modalSearchBars = getByTestId("modal-search-bars"); - expect(modalSearchBars).toBeTruthy(); - modalSearchBars.props.setCustomSearchText("Custom Search Input"); + expect(require('react-native-maps').default.animateToRegion).toHaveBeenCalled(); }); }); - - test("calculates different circle radius values based on zoom", async () => { + + test('handles floor number changes', async () => { + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.497092, longitude: -73.579037 }), + buildingName: 'Hall Building', + room: JSON.stringify({ building: 'H', id: 'H-801', name: 'H-801', location: { x: 1, y: 1 } }), + roomCoordinates: JSON.stringify({ x: 1, y: 1 }), + }); + require('../../utils/indoorUtils').getFloorNumber.mockReturnValue('8'); const { getByTestId } = render(); - const mapView = getByTestId("map-view"); - - // Trigger a region change that leads to zoom level 10 - const region = { - latitude: 45.5017, - longitude: -73.5673, - latitudeDelta: 5.625, // approx => zoom 10 - longitudeDelta: 5.625, - }; - await act(async () => { - fireEvent(mapView, "onRegionChangeComplete", region); + jest.advanceTimersByTime(1000); + }); + + // Trigger floor selector change + const floorSelector = getByTestId('floor-selector'); + fireEvent(floorSelector, 'setFloorNumber', { H: 2 }); + + // Trigger timer for floor change animation + await act(async () => { + jest.advanceTimersByTime(500); + }); + + expect(getByTestId('map-view')).toBeTruthy(); + }); + + test('handles room-to-room indoor navigation', async () => { + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.497092, longitude: -73.579037 }), + buildingName: 'Hall Building', + room: JSON.stringify({ building: 'H', id: 'H-801', name: 'H-801', location: { x: 1, y: 1 } }), + roomCoordinates: JSON.stringify({ x: 1, y: 1 }), + startRoom: JSON.stringify({ building: 'H', id: 'H-201', name: 'H-201', location: { x: 2, y: 2 } }), + }); + require('../../utils/indoorUtils').getFloorNumber + .mockReturnValueOnce('8') // For destination room + .mockReturnValueOnce('2'); // For start room + + const { getByTestId } = render(); + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + await waitFor(() => { + expect(getByTestId('polyline')).toBeTruthy(); + }); + }); + + test('handles rooms on different buildings', async () => { + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.497092, longitude: -73.579037 }), + buildingName: 'Hall Building', + room: JSON.stringify({ building: 'H', id: 'H-801', name: 'H-801', location: { x: 1, y: 1 } }), + roomCoordinates: JSON.stringify({ x: 1, y: 1 }), + startRoom: JSON.stringify({ building: 'MB', id: 'MB-201', name: 'MB-201', location: { x: 2, y: 2 } }), + }); + require('../../utils/indoorUtils').getFloorNumber + .mockReturnValueOnce('8') // For destination room + .mockReturnValueOnce('2'); // For start room + + const { getByTestId } = render(); + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + await waitFor(() => { + expect(getByTestId('polyline')).toBeTruthy(); }); - - // Radius should adjust after zoom - const radius = 20 * Math.pow(2, 15 - 10); // baseRadius * 2^(15 - zoom) - expect(radius).toBe(20 * 32); + }); + + test('handles indoor routes with special floor combinations', async () => { + // First test 8th and 9th floor special case + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.497092, longitude: -73.579037 }), + buildingName: 'Hall Building', + room: JSON.stringify({ building: 'H', id: 'H-901', name: 'H-901', location: { x: 1, y: 1 } }), + roomCoordinates: JSON.stringify({ x: 1, y: 1 }), + }); + require('../../utils/indoorUtils').getFloorNumber.mockReturnValue('9'); + + const { getByTestId, rerender } = render(); + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + expect(getByTestId('polyline')).toBeTruthy(); + + // Now test with same floor but different room locations + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.497092, longitude: -73.579037 }), + buildingName: 'Hall Building', + room: JSON.stringify({ building: 'H', id: 'H-101', name: 'H-101', location: { x: 1, y: 1 } }), + roomCoordinates: JSON.stringify({ x: 1, y: 1 }), + startRoom: JSON.stringify({ building: 'H', id: 'H-102', name: 'H-102', location: { x: 10, y: 10 } }), + }); + require('../../utils/indoorUtils').getFloorNumber.mockReturnValue('1'); + + rerender(); + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + expect(getByTestId('polyline')).toBeTruthy(); }); + test('handles empty steps in route', async () => { + global.fetch.mockResolvedValueOnce({ + json: () => Promise.resolve({ + routes: [{ + legs: [{ + distance: { text: '0 km' }, + duration: { text: '0 mins' }, + steps: null, // Null steps case + }], + }], + }), + }); + + const { getByText } = render(); + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + await waitFor(() => { + expect(getByText('No route steps available')).toBeTruthy(); + }); + }); + + test('calculates circle radius based on zoom level', async () => { + const { getByTestId } = render(); + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + const map = getByTestId('map-view'); + + // Test different zoom levels by changing region + const zoomLevels = [ + { delta: 10.0, zoom: 'very low' }, // Very zoomed out + { delta: 1.0, zoom: 'low' }, // Zoomed out + { delta: 0.1, zoom: 'medium' }, // Medium zoom + { delta: 0.01, zoom: 'high' }, // Zoomed in + { delta: 0.001, zoom: 'very high' }, // Very zoomed in + ]; + + for (const level of zoomLevels) { + fireEvent(map, 'onRegionChange', { + latitude: 45.497092, + longitude: -73.579037, + latitudeDelta: level.delta, + longitudeDelta: level.delta, + }); + + // Verify the circle is rendered with appropriate size + expect(getByTestId('circle')).toBeTruthy(); + } + }); + + test('handles special edge cases in handleMarkerTitle', async () => { + // Test tempRoomCoordinates case + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.497092, longitude: -73.579037 }), + buildingName: 'Hall Building', + room: JSON.stringify({ building: 'H', id: 'H-801', name: 'H-801', location: { x: 1, y: 1 } }), + roomCoordinates: JSON.stringify({ x: 1, y: 1 }), + }); + require('../../utils/indoorUtils').getFloorNumber.mockReturnValue('8'); + + // Mock the component to force tempRoomCoordinates state + const { getByTestId, rerender } = render(); + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + // Now test MB building case + require('expo-router').useLocalSearchParams.mockReturnValue({ + destination: JSON.stringify({ latitude: 45.495587, longitude: -73.577855 }), + buildingName: 'JMSB Building', + room: JSON.stringify({ building: 'MB', id: 'MB-801', name: 'MB-801', location: { x: 1, y: 1 } }), + roomCoordinates: JSON.stringify({ x: 1, y: 1 }), + }); + require('../../utils/indoorUtils').getFloorNumber.mockReturnValue('8'); + + rerender(); + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + expect(getByTestId('marker')).toBeTruthy(); + }); }); \ No newline at end of file diff --git a/FindMyClass/app/__tests__/SmartPlannerScreen.test.js b/FindMyClass/app/__tests__/SmartPlannerScreen.test.js index 90cbb306..1b986884 100644 --- a/FindMyClass/app/__tests__/SmartPlannerScreen.test.js +++ b/FindMyClass/app/__tests__/SmartPlannerScreen.test.js @@ -1,214 +1,830 @@ import React from 'react'; -import { render, fireEvent, waitFor, act } from '@testing-library/react-native'; -import { Alert } from 'react-native'; +import { render, fireEvent, waitFor } from '@testing-library/react-native'; import SmartPlannerScreen from '../screens/SmartPlannerScreen'; +import { Alert } from 'react-native'; import { generateSmartPlan } from '../api/smartPlannerService'; import * as Location from 'expo-location'; import axios from 'axios'; -import SGWBuildings from '../../components/SGWBuildings'; - -// Mock dependencies -jest.mock('../api/smartPlannerService', () => ({ - generateSmartPlan: jest.fn() -})); - -jest.mock('expo-location', () => ({ - requestForegroundPermissionsAsync: jest.fn(), - getCurrentPositionAsync: jest.fn() -})); +import { act } from 'react-test-renderer'; -jest.mock('axios', () => ({ - get: jest.fn() -})); - -// Mock vector icons and other dependencies +// Mock required dependencies jest.mock('@expo/vector-icons', () => ({ - Ionicons: 'Ionicons', - MaterialIcons: 'MaterialIcons', - FontAwesome5: 'FontAwesome5', - MaterialCommunityIcons: 'MaterialCommunityIcons' + Ionicons: () => null, + MaterialIcons: () => null, + FontAwesome5: () => null, + MaterialCommunityIcons: () => null, })); jest.mock('react-native-maps', () => { const { View } = require('react-native'); + const MockMapView = (props) => {props.children}; + MockMapView.Marker = (props) => {props.children}; + MockMapView.Polyline = (props) => {props.children}; + return { __esModule: true, - default: View, - Marker: View, - Polyline: View, - MapView: View + default: MockMapView, + Marker: MockMapView.Marker, + Polyline: MockMapView.Polyline, }; }); -jest.mock('react-native-safe-area-context', () => ({ - useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) -})); - jest.mock('expo-linear-gradient', () => { const { View } = require('react-native'); return { - LinearGradient: View + LinearGradient: (props) => {props.children} }; }); -describe('SmartPlannerScreen', () => { - const HALL_BUILDING_COORDINATES = { - latitude: 45.497092, - longitude: -73.579037 - }; +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), +})); + +jest.mock('expo-location', () => ({ + requestForegroundPermissionsAsync: jest.fn(), + getCurrentPositionAsync: jest.fn(), +})); + +jest.mock('axios', () => ({ + get: jest.fn(), +})); +jest.mock('../api/smartPlannerService', () => ({ + generateSmartPlan: jest.fn(), +})); + +// Test data +const HALL_BUILDING_COORDINATES = { + latitude: 45.497092, + longitude: -73.579037 +}; + +const MOCK_PLAN_RESULT = { + totalTimeMinutes: 25, + totalDistance: 1200, + totalIndoorPercentage: 70, + weatherAdvisory: "Weather is favorable.", + routeSummary: "Your optimal route", + steps: [ + { + building: { + id: "H", + name: "Hall Building", + latitude: 45.497092, + longitude: -73.579037 + }, + instruction: "Start at Hall Building", + timeEstimate: 0, + distance: 0, + indoorPercentage: 100 + } + ] +}; + +describe('SmartPlannerScreen', () => { + // Setup before each test beforeEach(() => { - // Reset all mocks jest.clearAllMocks(); - - // Spy on Alert.alert - jest.spyOn(Alert, 'alert'); - // Mock location setup + jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + + // Default mock implementations Location.requestForegroundPermissionsAsync.mockResolvedValue({ status: 'granted' }); - Location.getCurrentPositionAsync.mockResolvedValue({ - coords: HALL_BUILDING_COORDINATES - }); - - // Mock weather API + Location.getCurrentPositionAsync.mockResolvedValue({ coords: HALL_BUILDING_COORDINATES }); + axios.get.mockResolvedValue({ data: { main: { temp: 20 }, weather: [{ main: 'Clear' }] } }); + + generateSmartPlan.mockResolvedValue(MOCK_PLAN_RESULT); + + // Mock timers + jest.useFakeTimers(); + }); - // Reset generateSmartPlan mock - generateSmartPlan.mockReset(); + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); }); - const renderComponent = (props = {}) => { - const defaultProps = { - navigation: { - navigate: jest.fn(), - goBack: jest.fn() - } - }; - return render(); - }; + test('renders without crashing', () => { + const navigation = { navigate: jest.fn() }; + const { getByText } = render(); + + // Basic check to ensure component rendered + expect(getByText('Smart Planner')).toBeTruthy(); + }); - const addTask = async (screen, taskDescription = 'Attend lecture') => { - const taskInput = screen.getByPlaceholderText('Add a new task...'); - const addTaskButton = screen.getByTestId('add-task-button'); + test('toggles campus when pressed', () => { + const navigation = { navigate: jest.fn() }; + const { getByText } = render(); + + // Initial state is SGW + expect(getByText('SGW Campus')).toBeTruthy(); + + // Toggle campus + fireEvent.press(getByText('SGW Campus')); + + // Check that it changed to Loyola + expect(getByText('Loyola Campus')).toBeTruthy(); + }); - // Add task - await act(async () => { - fireEvent.changeText(taskInput, taskDescription); - fireEvent.press(addTaskButton); - }); - }; + test('can add a task', async () => { // Add async + const navigation = { navigate: jest.fn() }; + const { getByPlaceholderText, getByTestId } = render( + + ); + + // Add await before fireEvent + await fireEvent.changeText(getByPlaceholderText('Add a new task...'), 'Test task'); + await fireEvent.press(getByTestId('add-task-button')); + }, 10000); // Increase timeout + + test('can add and remove a task', () => { + const navigation = { navigate: jest.fn() }; + const { getByPlaceholderText, getByTestId, queryAllByTestId } = render( + + ); + + // Add a task + const input = getByPlaceholderText('Add a new task...'); + const addButton = getByTestId('add-task-button'); + + fireEvent.changeText(input, 'Test task'); + fireEvent.press(addButton); + + // Check task exists + const removeButtons = queryAllByTestId(/remove-task-/); + expect(removeButtons.length).toBe(1); + + // Remove the task + fireEvent.press(removeButtons[0]); + + // Check task was removed + const updatedRemoveButtons = queryAllByTestId(/remove-task-/); + expect(updatedRemoveButtons.length).toBe(0); + }); - const generatePlan = async (screen) => { - const generatePlanButton = screen.getByText('Generate Optimized Plan'); + test('prevents adding empty task', () => { + const navigation = { navigate: jest.fn() }; + const { getByPlaceholderText, getByTestId, queryAllByTestId } = render( + + ); - await act(async () => { - fireEvent.press(generatePlanButton); - }); - }; + // Try to add empty task + const input = getByPlaceholderText('Add a new task...'); + const addButton = getByTestId('add-task-button'); + + fireEvent.changeText(input, ''); + fireEvent.press(addButton); + + // Check no task was added + const removeButtons = queryAllByTestId(/remove-task-/); + expect(removeButtons.length).toBe(0); + }); - it('handles error scenarios', async () => { - // Mock error in plan generation - generateSmartPlan.mockRejectedValue(new Error('Server error')); + test('can modify task text', () => { + const navigation = { navigate: jest.fn() }; + const { getByPlaceholderText, getByTestId, getByDisplayValue } = render( + + ); + + // Add a task + const input = getByPlaceholderText('Add a new task...'); + const addButton = getByTestId('add-task-button'); + + fireEvent.changeText(input, 'Original task'); + fireEvent.press(addButton); + + // Find and modify the task + const taskInput = getByDisplayValue('Original task'); + fireEvent.changeText(taskInput, 'Modified task'); + + // Verify modification + expect(getByDisplayValue('Modified task')).toBeTruthy(); + }); - const screen = renderComponent(); + test('shows alert when generating plan with no tasks', () => { + const navigation = { navigate: jest.fn() }; + const { getByText } = render(); + + // Try to generate plan with no tasks + fireEvent.press(getByText('Generate Optimized Plan')); + + // Check error alert + expect(Alert.alert).toHaveBeenCalledWith( + 'Error', + 'Please add at least one task.' + ); + }); + + test('handles location permission denied', async () => { + Location.requestForegroundPermissionsAsync.mockResolvedValue({ status: 'denied' }); + + const navigation = { navigate: jest.fn() }; + render(); + + // Wait for useEffect to complete + await act(async () => { + await Promise.resolve(); + }); + + expect(Alert.alert).toHaveBeenCalledWith( + 'Permission Denied', + 'We need location permissions to optimize your route.' + ); + }); - // Wait for component to initialize + test('handles location error', async () => { + // Mock location error + Location.requestForegroundPermissionsAsync.mockRejectedValue(new Error('Location error')); + + const navigation = { navigate: jest.fn() }; + render(); + + // Advanced timers + jest.runAllTimers(); + + // Wait for the alert to be called await waitFor(() => { - expect(screen.getByText('Smart Planner')).toBeTruthy(); + expect(Alert.alert).toHaveBeenCalledWith( + 'Error', + 'Failed to get location. Please try again.' + ); }); + }); - // Add a task to bypass initial validation - await addTask(screen); + test('handles weather API error', () => { + // Mock weather API error + axios.get.mockRejectedValue(new Error('Weather API error')); + + const navigation = { navigate: jest.fn() }; + const { getByText } = render(); + + // Run all timers to allow promises to resolve + jest.runAllTimers(); + + // Component should still render without crashing + expect(getByText('Smart Planner')).toBeTruthy(); + }); + test('handles plan generation with location but no tasks', () => { + const navigation = { navigate: jest.fn() }; + const { getByText } = render(); + + // Try to generate plan with no tasks + fireEvent.press(getByText('Generate Optimized Plan')); + + // Check error alert for no tasks + expect(Alert.alert).toHaveBeenCalledWith( + 'Error', + 'Please add at least one task.' + ); + }); + + test('handles successful plan generation', async () => { + const navigation = { navigate: jest.fn() }; + const { getByText, getByPlaceholderText, getByTestId } = render( + + ); + + // Add a task + const input = getByPlaceholderText('Add a new task...'); + const addButton = getByTestId('add-task-button'); + + fireEvent.changeText(input, 'Visit library'); + fireEvent.press(addButton); + + // Mock successful generation + generateSmartPlan.mockResolvedValue(MOCK_PLAN_RESULT); + // Generate plan - await generatePlan(screen); + fireEvent.press(getByText('Generate Optimized Plan')); + + // Run timers to resolve promises and animations + jest.runAllTimers(); + + // We can't easily test the plan rendering due to animations, + // but we can verify the function was called with correct params + expect(generateSmartPlan).toHaveBeenCalled(); + }); - // Verify error handling + test('handles plan generation error', async () => { + // Mock error in plan generation + generateSmartPlan.mockRejectedValue(new Error('Plan generation error')); + + const navigation = { navigate: jest.fn() }; + const { getByText, getByPlaceholderText, getByTestId } = render( + + ); + + // Add a task + const input = getByPlaceholderText('Add a new task...'); + const addButton = getByTestId('add-task-button'); + + fireEvent.changeText(input, 'Visit library'); + fireEvent.press(addButton); + + // Generate plan + fireEvent.press(getByText('Generate Optimized Plan')); + + // Run timers + jest.runAllTimers(); + + // Wait for alert await waitFor(() => { expect(Alert.alert).toHaveBeenCalledWith( - 'Error', - 'Please add at least one task.' + 'Error', + 'Failed to generate plan. Please try again.' ); }); }); - it('handles empty task generation attempt', async () => { - const screen = renderComponent(); + test('handles getWeatherIcon for different conditions', () => { + // Test rainy weather + axios.get.mockResolvedValue({ + data: { + main: { temp: 10 }, + weather: [{ main: 'Rain' }] + } + }); + + const navigation = { navigate: jest.fn() }; + render(); + + // Run timers to resolve promises + jest.runAllTimers(); + + // Test cloudy weather + axios.get.mockResolvedValue({ + data: { + main: { temp: 18 }, + weather: [{ main: 'Clouds' }] + } + }); + + render(); + + // Run timers + jest.runAllTimers(); + + // Test sunny weather + axios.get.mockResolvedValue({ + data: { + main: { temp: 25 }, + weather: [{ main: 'Clear' }] + } + }); + + render(); + + // Run timers + jest.runAllTimers(); + + // The function getWeatherIcon is internal, so we can't directly test its return value, + // but we can verify the component renders with different weather conditions + }); - // Wait for component to initialize - await waitFor(() => { - expect(screen.getByText('Smart Planner')).toBeTruthy(); + test('shows alert when all tasks are empty', () => { + const navigation = { navigate: jest.fn() }; + const { getByText, getByPlaceholderText, getByTestId } = render( + + ); + + // Add empty task + fireEvent.changeText(getByPlaceholderText('Add a new task...'), ' '); + fireEvent.press(getByTestId('add-task-button')); + + // Try to generate plan + fireEvent.press(getByText('Generate Optimized Plan')); + + expect(Alert.alert).toHaveBeenCalledWith( + 'Error', + 'Please add at least one task.' + ); + }); + + test('toggles map fullscreen', async () => { + const navigation = { navigate: jest.fn() }; + const { getByText, getByPlaceholderText, getByTestId } = render( + + ); + + // Add task and generate plan first + fireEvent.changeText(getByPlaceholderText('Add a new task...'), 'Test task'); + fireEvent.press(getByTestId('add-task-button')); + fireEvent.press(getByText('Generate Optimized Plan')); + + // Wait for plan to generate + await act(async () => { + jest.runAllTimers(); }); + + // Find and click fullscreen button + fireEvent.press(getByTestId('mapview').parent.findByProps({testID: 'mapFullscreenButton'})); + + // Verify fullscreen state + // This might need adjustment based on your actual implementation + expect(getByTestId('mapview').parent.props.style.height).toBe(height); + }); - // Generate plan without tasks - await generatePlan(screen); + test('selects journey segments', async () => { + const navigation = { navigate: jest.fn() }; + const { getByText, getByPlaceholderText, getByTestId } = render( + + ); + + // Add task and generate plan + fireEvent.changeText(getByPlaceholderText('Add a new task...'), 'Test task'); + fireEvent.press(getByTestId('add-task-button')); + fireEvent.press(getByText('Generate Optimized Plan')); + + await act(async () => { + jest.runAllTimers(); + }); + + // Find and click a segment + const segmentButton = getByTestId('segment-button-0'); + fireEvent.press(segmentButton); + + // Verify active segment is updated + expect(segmentButton.props.style).toContainEqual( + expect.objectContaining({ backgroundColor: CONCORDIA_BURGUNDY }) + ); + }); - // Verify error alert for no tasks - await waitFor(() => { - expect(Alert.alert).toHaveBeenCalledWith( - 'Error', - 'Please add at least one task.' - ); + test('selects correct weather icons', async () => { + // Mock rainy weather + axios.get.mockResolvedValue({ + data: { + main: { temp: 10 }, + weather: [{ main: 'Rain' }] + } }); + + const navigation = { navigate: jest.fn() }; + const { findByProps, getByText } = render(); + + await act(async () => { + jest.runAllTimers(); + }); + + // Verify rainy icon is shown in weather info + const weatherInfo = getByText(/°C, Rain/); + expect(weatherInfo).toBeTruthy(); }); + + test('shows correct colors for indoor percentages', async () => { + const navigation = { navigate: jest.fn() }; + const { getByText, getByPlaceholderText, getByTestId, getAllByTestId } = render( + + ); + + // Add task + fireEvent.changeText(getByPlaceholderText('Add a new task...'), 'Test task'); + fireEvent.press(getByTestId('add-task-button')); + + // Mock plan with different indoor percentages + const mockPlan = { + ...MOCK_PLAN_RESULT, + steps: [ + { + building: { id: "H", name: "Hall", latitude: 45.497, longitude: -73.579 }, + instruction: "Step 1", + timeEstimate: 5, + distance: 100, + indoorPercentage: 90 // Mostly indoor + }, + { + building: { id: "LB", name: "Library", latitude: 45.4975, longitude: -73.5795 }, + instruction: "Step 2", + timeEstimate: 5, + distance: 100, + indoorPercentage: 50 // Mixed + } + ] + }; + generateSmartPlan.mockResolvedValue(mockPlan); + + // Generate plan + fireEvent.press(getByText('Generate Optimized Plan')); + await act(async () => { + jest.runAllTimers(); + }); + + // Verify badge colors (you'll need to add testID="indoor-badge" to your badge component) + const badges = getAllByTestId('indoor-badge'); + expect(badges[0].props.style.backgroundColor).toBe('#23A55A'); // Mostly indoor + expect(badges[1].props.style.backgroundColor).toBe('#FFB302'); // Mixed + }); - it('toggles campus between SGW and Loyola', async () => { - const screen = renderComponent(); + test('calculates correct map region for route', async () => { + const navigation = { navigate: jest.fn() }; + const { getByText, getByPlaceholderText, getByTestId } = render( + + ); + + // Add task + fireEvent.changeText(getByPlaceholderText('Add a new task...'), 'Test task'); + fireEvent.press(getByTestId('add-task-button')); + + // Mock plan with multiple locations + const mockPlan = { + ...MOCK_PLAN_RESULT, + steps: [ + { + building: { + id: "H", + name: "Hall Building", + latitude: 45.497092, + longitude: -73.579037 + }, + // ... other properties + }, + { + building: { + id: "LB", + name: "Library Building", + latitude: 45.4975, + longitude: -73.5795 + }, + // ... other properties + } + ] + }; + generateSmartPlan.mockResolvedValue(mockPlan); + + // Generate plan + fireEvent.press(getByText('Generate Optimized Plan')); + + await act(async () => { + jest.runAllTimers(); + }); + + // Verify map region was calculated correctly + const mapView = getByTestId('mapview'); + expect(mapView.props.region.latitudeDelta).toBeGreaterThan(0); + expect(mapView.props.region.longitudeDelta).toBeGreaterThan(0); + }); - // Wait for component to initialize - await waitFor(() => { - expect(screen.getByText('Smart Planner')).toBeTruthy(); + test('renders polyline for route', async () => { + const navigation = { navigate: jest.fn() }; + const { getByText, getByPlaceholderText, getByTestId } = render( + + ); + + // Add task and generate plan + fireEvent.changeText(getByPlaceholderText('Add a new task...'), 'Test task'); + fireEvent.press(getByTestId('add-task-button')); + fireEvent.press(getByText('Generate Optimized Plan')); + + await act(async () => { + jest.runAllTimers(); }); + + // Verify polyline exists with correct coordinates + const polyline = getByTestId('polyline'); + expect(polyline.props.coordinates.length).toBeGreaterThan(1); + expect(polyline.props.strokeColor).toBe('#912338'); + }); - // Initial campus - expect(screen.getByText('SGW Campus')).toBeTruthy(); + test('renders markers for buildings', async () => { + const navigation = { navigate: jest.fn() }; + const { getByText, getByPlaceholderText, getAllByTestId } = render( + + ); + + // Add task and generate plan + fireEvent.changeText(getByPlaceholderText('Add a new task...'), 'Test task'); + fireEvent.press(getByText('Add')); + fireEvent.press(getByText('Generate Optimized Plan')); + + await act(async () => { + jest.runAllTimers(); + }); + + // Verify markers exist + const markers = getAllByTestId('marker'); + expect(markers.length).toBeGreaterThan(0); + }); - // Toggle campus - const campusToggle = screen.getByText('SGW Campus'); + test('shows weather advisory when precipitation', async () => { + // Mock rainy weather + axios.get.mockResolvedValue({ + data: { + main: { temp: 10 }, + weather: [{ main: 'Rain' }] + } + }); + + const navigation = { navigate: jest.fn() }; + const { findByText } = render(); + + await act(async () => { + jest.runAllTimers(); + }); + + // Add task and generate plan + fireEvent.changeText(getByPlaceholderText('Add a new task...'), 'Test task'); + fireEvent.press(getByTestId('add-task-button')); + fireEvent.press(getByText('Generate Optimized Plan')); + + await act(async () => { + jest.runAllTimers(); + }); + + // Verify advisory appears + const advisory = await findByText('Rain detected'); + expect(advisory).toBeTruthy(); + }); + + test('updates active segment when scrolling', async () => { + const navigation = { navigate: jest.fn() }; + const { getByText, getByPlaceholderText, getByTestId } = render( + + ); + + // Add task and generate plan with multiple segments + fireEvent.changeText(getByPlaceholderText('Add a new task...'), 'Test task'); + fireEvent.press(getByTestId('add-task-button')); + + const mockPlan = { + ...MOCK_PLAN_RESULT, + steps: Array(5).fill().map((_, i) => ({ + building: { + id: `B${i}`, + name: `Building ${i}`, + latitude: 45.497092 + i * 0.001, + longitude: -73.579037 + i * 0.001 + }, + instruction: `Step ${i}`, + timeEstimate: 5, + distance: 200, + indoorPercentage: 50 + })) + }; + generateSmartPlan.mockResolvedValue(mockPlan); + fireEvent.press(getByText('Generate Optimized Plan')); + await act(async () => { - fireEvent.press(campusToggle); + jest.runAllTimers(); }); + + // Find the FlatList and simulate scroll + const flatList = getByTestId('segment-flatlist'); // Add testID to your FlatList + fireEvent.scroll(flatList, { + nativeEvent: { + contentOffset: { x: width * 2 }, // Scroll to third segment + contentSize: { width: width * 5, height: 0 }, + layoutMeasurement: { width, height: 0 } + } + }); + + // Verify active segment updated + expect(getByText('Building 2')).toBeTruthy(); // Third segment should be active + }); - // Check campus changed - await waitFor(() => { - expect(screen.getByText('Loyola Campus')).toBeTruthy(); + test('handles empty plan result from API', async () => { + const navigation = { navigate: jest.fn() }; + const { getByText, getByPlaceholderText, getByTestId, queryByText } = render( + + ); + + // Add task + fireEvent.changeText(getByPlaceholderText('Add a new task...'), 'Test task'); + fireEvent.press(getByTestId('add-task-button')); + + // Mock empty response + generateSmartPlan.mockResolvedValue({}); + + // Generate plan + fireEvent.press(getByText('Generate Optimized Plan')); + + await act(async () => { + jest.runAllTimers(); }); + + // Verify no journey is shown + expect(queryByText('Your Journey')).toBeNull(); }); - it('handles weather conditions correctly', async () => { - // Test various weather conditions - const weatherScenarios = [ - { main: 'Clouds', precipitation: false }, - { main: 'Rain', precipitation: true }, - { main: 'Snow', precipitation: true }, - { main: 'Clear', precipitation: false } - ]; + test('handles single-segment journey', async () => { + const navigation = { navigate: jest.fn() }; + const { getByText, getByPlaceholderText, getByTestId, queryAllByTestId } = render( + + ); + + // Add task + fireEvent.changeText(getByPlaceholderText('Add a new task...'), 'Test task'); + fireEvent.press(getByTestId('add-task-button')); + + // Mock single-segment response + generateSmartPlan.mockResolvedValue({ + ...MOCK_PLAN_RESULT, + steps: [MOCK_PLAN_RESULT.steps[0]] // Only keep first step + }); + + // Generate plan + fireEvent.press(getByText('Generate Optimized Plan')); + + await act(async () => { + jest.runAllTimers(); + }); + + // Verify no progress lines are rendered + expect(queryAllByTestId('progress-line').length).toBe(0); + }); - for (const scenario of weatherScenarios) { - // Reset mocks - jest.clearAllMocks(); + test('handles segments without buildings', async () => { + const navigation = { navigate: jest.fn() }; + const { getByText, getByPlaceholderText, getByTestId, queryAllByTestId } = render( + + ); + + // Add task + fireEvent.changeText(getByPlaceholderText('Add a new task...'), 'Test task'); + fireEvent.press(getByTestId('add-task-button')); + + // Mock segment without building + generateSmartPlan.mockResolvedValue({ + ...MOCK_PLAN_RESULT, + steps: [{ + instruction: "Walk to next location", + timeEstimate: 5, + distance: 200, + indoorPercentage: 20 + }] + }); + + // Generate plan + fireEvent.press(getByText('Generate Optimized Plan')); + + await act(async () => { + jest.runAllTimers(); + }); + + // Verify no markers rendered + expect(queryAllByTestId('marker').length).toBe(0); + }); - // Mock weather API - axios.get.mockResolvedValue({ - data: { - main: { temp: 20 }, - weather: [{ main: scenario.main }] - } - }); + test('shows precipitation advisory when weather has precipitation', async () => { + // Mock weather with precipitation + axios.get.mockResolvedValue({ + data: { + main: { temp: 5 }, + weather: [{ main: 'Snow' }] // Precipitation type + } + }); + + const navigation = { navigate: jest.fn() }; + const { findByText } = render(); + + await act(async () => { + jest.runAllTimers(); + }); + + // Add task and generate plan + fireEvent.changeText(getByPlaceholderText('Add a new task...'), 'Test task'); + fireEvent.press(getByTestId('add-task-button')); + fireEvent.press(getByText('Generate Optimized Plan')); + + await act(async () => { + jest.runAllTimers(); + }); + + // Verify advisory appears + const advisory = await findByText(/minimizes outdoor exposure/); + expect(advisory).toBeTruthy(); + }); + test('handles single-coordinate route for map region', async () => { + const navigation = { navigate: jest.fn() }; + const { getByText, getByPlaceholderText, getByTestId } = render( + + ); + + // Add task + fireEvent.changeText(getByPlaceholderText('Add a new task...'), 'Test task'); + fireEvent.press(getByTestId('add-task-button')); + + // Mock plan with only current location (no buildings) + generateSmartPlan.mockResolvedValue({ + ...MOCK_PLAN_RESULT, + steps: [] + }); - const screen = renderComponent(); + // Generate plan + fireEvent.press(getByText('Generate Optimized Plan')); - // Wait for weather to load - await waitFor(() => { - expect(screen.getByText(new RegExp(`${scenario.main}`, 'i'))).toBeTruthy(); - }); - } + await act(async () => { + jest.runAllTimers(); }); - + + // Verify default region is used + const mapView = getByTestId('mapview'); + expect(mapView.props.region.latitudeDelta).toBe(0.01); +}); }); \ No newline at end of file diff --git a/FindMyClass/components/__tests__/Chatbot.test.js b/FindMyClass/components/__tests__/Chatbot.test.js index 72e78cfa..89bc7dcd 100644 --- a/FindMyClass/components/__tests__/Chatbot.test.js +++ b/FindMyClass/components/__tests__/Chatbot.test.js @@ -1,244 +1,200 @@ import React from 'react'; import { render, fireEvent, waitFor, act } from '@testing-library/react-native'; -import Chatbot from '../Chatbot'; +import Chatbot from '../Chatbot'; // Adjust path as needed import { sendConversationToOpenAI } from '../../services/openai'; import fetchGoogleCalendarEvents from '../../app/api/googleCalendar'; +import { useRouter } from 'expo-router'; -// Use fake timers for popup timer testing -jest.useFakeTimers(); +// Mock the required dependencies +jest.mock('../../services/openai', () => ({ + sendConversationToOpenAI: jest.fn() +})); + +jest.mock('../../app/api/googleCalendar', () => jest.fn()); -// Mock Expo Router -const mockPush = jest.fn(); jest.mock('expo-router', () => ({ - useRouter: () => ({ - push: mockPush, - }), + useRouter: jest.fn() })); -// Mock OpenAI API call -jest.mock('../../services/openai', () => ({ - sendConversationToOpenAI: jest.fn(), +jest.mock('uuid', () => ({ + v4: jest.fn(() => 'test-uuid') })); -// Mock Google Calendar API -jest.mock('../../app/api/googleCalendar', () => jest.fn()); +// Mock timer functions +jest.useFakeTimers(); describe('Chatbot Component', () => { - const onCloseMock = jest.fn(); + const mockOnClose = jest.fn(); + const mockPush = jest.fn(); beforeEach(() => { + // Reset all mocks jest.clearAllMocks(); + + // Mock router + useRouter.mockReturnValue({ + push: mockPush + }); + + // Mock OpenAI response + sendConversationToOpenAI.mockResolvedValue('This is a bot response'); + + // Mock Google Calendar events + fetchGoogleCalendarEvents.mockResolvedValue([]); }); - - // Basic rendering (covers parts of the component header, text input, etc.) + it('renders correctly when visible', () => { const { getByText, getByPlaceholderText } = render( - + ); + expect(getByText('Campus Guide Chatbot')).toBeTruthy(); expect(getByPlaceholderText('Type your message...')).toBeTruthy(); + expect(getByText('Send')).toBeTruthy(); }); - // Close button functionality (covering a small branch in the header) - it('calls onClose when the close button is pressed', () => { + it('closes when the close button is pressed', () => { const { getByText } = render( - + ); - const closeButton = getByText('✕'); - fireEvent.press(closeButton); - expect(onCloseMock).toHaveBeenCalled(); + + fireEvent.press(getByText('✕')); + expect(mockOnClose).toHaveBeenCalledTimes(1); }); - // Send a basic message (covers the default conversation building path) - it('sends a message and displays bot response', async () => { - const fakeBotResponse = 'This is a bot response'; - // Simulate no events so that schedule logic is skipped - fetchGoogleCalendarEvents.mockResolvedValueOnce([]); - sendConversationToOpenAI.mockResolvedValueOnce(fakeBotResponse); - - const { getByPlaceholderText, getByText } = render( - + it('sends a message and receives a response', async () => { + const { getByText, getByPlaceholderText, findByText } = render( + ); - + const input = getByPlaceholderText('Type your message...'); - fireEvent.changeText(input, 'Hello Chatbot'); + fireEvent.changeText(input, 'Hello'); + const sendButton = getByText('Send'); fireEvent.press(sendButton); - - // Check that the user's message is rendered - await waitFor(() => expect(getByText('Hello Chatbot')).toBeTruthy()); - // Wait for the bot response to appear - await waitFor(() => expect(getByText(fakeBotResponse)).toBeTruthy()); + + // Verify the message was added + expect(getByText('Hello')).toBeTruthy(); + + // Wait for the bot response + await waitFor(() => { + expect(sendConversationToOpenAI).toHaveBeenCalled(); + }); + + const botResponse = await findByText('This is a bot response'); + expect(botResponse).toBeTruthy(); }); - // Covering the "next class" branch (lines ~71–73, and parts of the inline directions bubble) - it('processes a "next class" query and shows inline directions bubble', async () => { - // Create a fake event with a future date and location - const futureDate = new Date(Date.now() + 3600000).toISOString(); // 1 hour ahead - const fakeEvent = { - summary: 'Calculus 101', - location: 'JMSB Room 101', - start: { dateTime: futureDate }, + + it('handles next class request with calendar events', async () => { + // Mock calendar events with a next class + const mockEvent = { + summary: 'Math 101', + location: 'JMSB Building', + start: { dateTime: new Date().toISOString() } }; - fetchGoogleCalendarEvents.mockResolvedValueOnce([fakeEvent]); - const fakeBotResponse = 'Here are your next class details'; - sendConversationToOpenAI.mockResolvedValueOnce(fakeBotResponse); - const { getByPlaceholderText, getByText, findByText, queryByText } = render( - + fetchGoogleCalendarEvents.mockResolvedValue([mockEvent]); + + const { getByPlaceholderText, getByText, findByText } = render( + ); const input = getByPlaceholderText('Type your message...'); fireEvent.changeText(input, 'What is my next class?'); + const sendButton = getByText('Send'); fireEvent.press(sendButton); - // Wait for the bot response to render - await waitFor(() => expect(getByText(fakeBotResponse)).toBeTruthy(), { timeout: 5000 }); - // Wait longer for the inline bubble to appear - const inlineBubble = await waitFor(() => getByText(/Get Directions to JMSB/), { timeout: 5000 }); - expect(inlineBubble).toBeTruthy(); - - // Optionally, advance timers to simulate popup dismissal - act(() => { - jest.advanceTimersByTime(10000); - }); await waitFor(() => { - expect(queryByText('Next Directions')).toBeNull(); + expect(fetchGoogleCalendarEvents).toHaveBeenCalled(); }); + + await findByText('This is a bot response'); + await findByText(/Get Directions to JMSB Building/i); + + // Verify the directions popup is shown + expect(sendConversationToOpenAI).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + content: expect.stringContaining('JMSB Building') + }) + ]) + ); }); - // Covering the "schedule" branch (lines ~107–120 and 131) - it('processes a "schedule" query and returns full schedule details', async () => { - const fakeEvents = [ + + it('handles general schedule request', async () => { + // Mock calendar events for schedule + const mockEvents = [ { - summary: 'Calculus 101', - location: 'JMSB Room 101', - start: { dateTime: new Date().toISOString() }, + summary: 'Math 101', + location: 'JMSB Building', + start: { dateTime: new Date(Date.now() + 3600000).toISOString() } }, { - summary: 'Physics 102', - location: 'EV Room 202', - start: { dateTime: new Date(Date.now() + 3600000).toISOString() }, - }, + summary: 'Physics 202', + location: 'EV Building', + start: { dateTime: new Date(Date.now() + 7200000).toISOString() } + } ]; - fetchGoogleCalendarEvents.mockResolvedValueOnce(fakeEvents); - const fakeBotResponse = 'Here is your full schedule'; - sendConversationToOpenAI.mockResolvedValueOnce(fakeBotResponse); - - const { getByPlaceholderText, getByText } = render( - + + fetchGoogleCalendarEvents.mockResolvedValue(mockEvents); + + const { getByPlaceholderText, getByText, findByText } = render( + ); - + const input = getByPlaceholderText('Type your message...'); - fireEvent.changeText(input, 'Show my schedule'); + fireEvent.changeText(input, 'Show me my schedule'); + const sendButton = getByText('Send'); fireEvent.press(sendButton); - - await waitFor(() => expect(getByText(fakeBotResponse)).toBeTruthy()); - // Check that the full schedule string (prepended with "Your full schedule:") is included - expect(getByText(/Your full schedule:/)).toBeTruthy(); - }); - - // Covering the branch when no events are returned (ensuring proper finalUserInput formatting) - it('handles schedule query with no events', async () => { - fetchGoogleCalendarEvents.mockResolvedValueOnce([]); - const fakeBotResponse = 'No upcoming events found.\n\nUser question: Show my schedule'; - sendConversationToOpenAI.mockResolvedValueOnce(fakeBotResponse); - - const { getByPlaceholderText, getByText } = render( - + + await waitFor(() => { + expect(fetchGoogleCalendarEvents).toHaveBeenCalled(); + }); + + await findByText('This is a bot response'); + expect(sendConversationToOpenAI).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + content: expect.stringContaining('Your full schedule') + }) + ]) ); - const input = getByPlaceholderText('Type your message...'); - fireEvent.changeText(input, 'Show my schedule'); - const sendButton = getByText('Send'); - fireEvent.press(sendButton); - await waitFor(() => expect(getByText(fakeBotResponse)).toBeTruthy()); }); - // Covering handleGetDirections (line ~173) and navigation (via inline bubble) - it('navigates to directions screen when inline directions button is pressed', async () => { - const fakeEvent = { - summary: 'Calculus 101', - location: 'JMSB Room 101', - start: { dateTime: new Date().toISOString() }, - }; - fetchGoogleCalendarEvents.mockResolvedValueOnce([fakeEvent]); - const fakeBotResponse = 'Here are your next class details'; - sendConversationToOpenAI.mockResolvedValueOnce(fakeBotResponse); - + it('handles API errors gracefully', async () => { + // Mock API failure + sendConversationToOpenAI.mockRejectedValue(new Error('API Error')); + const { getByPlaceholderText, getByText, findByText } = render( - - ); - - const input = getByPlaceholderText('Type your message...'); - fireEvent.changeText(input, 'What is my next class?'); - const sendButton = getByText('Send'); - fireEvent.press(sendButton); - - // Wait for inline bubble and then press it - const inlineButton = await findByText(/Get Directions to/); - fireEvent.press(inlineButton); - - // Verify that onClose was called and navigation occurred - expect(onCloseMock).toHaveBeenCalled(); - expect(mockPush).toHaveBeenCalled(); - const pushCallArgs = mockPush.mock.calls[0][0]; - expect(pushCallArgs.pathname).toBe('/screens/directions'); - expect(pushCallArgs.params).toHaveProperty('destination'); - expect(pushCallArgs.params).toHaveProperty('buildingName'); - }); - - // Covering the inline popup modal branch (lines ~185–192 and 243) - it('closes the popup modal when its overlay is pressed', async () => { - // Simulate a next class query that would trigger showing the popup modal. - const fakeEvent = { - summary: 'Calculus 101', - location: 'JMSB Room 101', - start: { dateTime: new Date().toISOString() }, - }; - fetchGoogleCalendarEvents.mockResolvedValueOnce([fakeEvent]); - const fakeBotResponse = 'Here are your next class details'; - sendConversationToOpenAI.mockResolvedValueOnce(fakeBotResponse); - - const { getByPlaceholderText, getByText, queryByText, findByText } = render( - + ); - + const input = getByPlaceholderText('Type your message...'); - fireEvent.changeText(input, 'What is my next class?'); + fireEvent.changeText(input, 'Hello'); + const sendButton = getByText('Send'); fireEvent.press(sendButton); - - // Wait for inline bubble to appear - await findByText(/Get Directions to/); - // The popup modal (with text "Next Directions") should also appear - const popupText = await findByText('Next Directions'); - expect(popupText).toBeTruthy(); - // Simulate pressing on the popup overlay/button to close it - fireEvent.press(popupText); - // Wait for the popup modal to be dismissed - await waitFor(() => { - expect(queryByText('Next Directions')).toBeNull(); - }); + + const errorMessage = await findByText('Error fetching response. Please try again.'); + expect(errorMessage).toBeTruthy(); }); - // Error handling: when sendConversationToOpenAI fails (covering other branch) - it('displays an error message when sendConversationToOpenAI fails', async () => { - sendConversationToOpenAI.mockRejectedValueOnce(new Error('API Error')); - fetchGoogleCalendarEvents.mockResolvedValueOnce([]); - + it('does not send empty messages', () => { const { getByPlaceholderText, getByText } = render( - + ); - + const input = getByPlaceholderText('Type your message...'); - fireEvent.changeText(input, 'Hello Chatbot'); + fireEvent.changeText(input, ' '); // Just whitespace + const sendButton = getByText('Send'); fireEvent.press(sendButton); - - await waitFor(() => - expect(getByText('Error fetching response. Please try again.')).toBeTruthy() - ); + + expect(sendConversationToOpenAI).not.toHaveBeenCalled(); }); + }); \ No newline at end of file