/** * MiddleEarthMap Component Tests - Quest Marker Clustering * Tests quest marker aggregation/disaggregation on zoom and location validation */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MapContainer, useMap } from 'react-leaflet'; import L from 'leaflet'; import React from 'react'; import { Location, Quest } from '../../src/types'; // Mock the markerCluster plugin vi.mock('leaflet.markercluster', () => ({}), { virtual: true }); // Test fixtures const mockLocations: Location[] = [ { id: 1, name: 'Rivendell', region: 'Imladris', description: 'The Hidden Valley', map_x: 500, map_y: 300 }, { id: 2, name: 'Lothlorien', region: 'Golden Wood', description: 'The Golden Forest', map_x: 400, map_y: 350 }, { id: 3, name: 'Moria', region: 'Dwarf Kingdom', description: 'The Mines of Moria', map_x: 600, map_y: 400 } ]; const mockQuestsRivendell: Quest[] = [ { id: 1, title: 'Meet the Council', description: 'Attend the Council of Rivendell', status: 'not_yet_begun', quest_type: 'The Fellowship', priority: 'Critical', location_id: 1, is_dark_magic: false, created_at: '2024-01-01', updated_at: '2024-01-01' }, { id: 2, title: 'Gather Provisions', description: 'Prepare for the journey', status: 'not_yet_begun', quest_type: 'The Journey', priority: 'Important', location_id: 1, is_dark_magic: false, created_at: '2024-01-01', updated_at: '2024-01-01' }, { id: 3, title: 'Study the Map', description: 'Learn the routes', status: 'the_road_goes_ever_on', quest_type: 'The Ring', priority: 'Critical', location_id: 1, is_dark_magic: false, created_at: '2024-01-01', updated_at: '2024-01-01' } ]; const mockQuestsLothlorien: Quest[] = [ { id: 10, title: 'Wood Elf Blessings', description: 'Seek blessings from the Wood Elves', status: 'not_yet_begun', quest_type: 'The Fellowship', priority: 'Standard', location_id: 2, is_dark_magic: false, created_at: '2024-01-01', updated_at: '2024-01-01' }, { id: 11, title: 'Gather Lembas Bread', description: 'Obtain supplies for the journey', status: 'not_yet_begun', quest_type: 'The Journey', priority: 'Important', location_id: 2, is_dark_magic: false, created_at: '2024-01-01', updated_at: '2024-01-01' } ]; const mockQuestsMoria: Quest[] = [ { id: 20, title: 'Navigate the Depths', description: 'Cross through the Mines of Moria', status: 'the_road_goes_ever_on', quest_type: 'The Battle', priority: 'Critical', location_id: 3, is_dark_magic: true, created_at: '2024-01-01', updated_at: '2024-01-01' } ]; const mockQuestsNoLocation: Quest[] = [ { id: 30, title: 'Orphan Quest', description: 'This quest has no location', status: 'not_yet_begun', quest_type: 'The Ring', priority: 'Standard', location_id: undefined, is_dark_magic: false, created_at: '2024-01-01', updated_at: '2024-01-01' } ]; describe('MiddleEarthMap - Quest Marker Clustering', () => { describe('Quest Marker Aggregation', () => { it('should aggregate multiple quest markers into a cluster', () => { const questsByLocation = { 1: mockQuestsRivendell, // 3 quests at Rivendell 2: mockQuestsLothlorien, // 2 quests at Lothlorien 3: mockQuestsMoria // 1 quest at Moria }; expect(questsByLocation[1].length).toBe(3); expect(questsByLocation[2].length).toBe(2); expect(questsByLocation[3].length).toBe(1); expect(Object.values(questsByLocation).flat().length).toBe(6); }); it('should handle single quest markers without clustering', () => { const questsByLocation = { 3: mockQuestsMoria }; expect(questsByLocation[3].length).toBe(1); }); it('should correctly identify quests without locations for filtering', () => { const allQuests = [...mockQuestsRivendell, ...mockQuestsMoria, ...mockQuestsNoLocation]; const questsWithLocation = allQuests.filter(q => q.location_id); const questsWithoutLocation = allQuests.filter(q => !q.location_id); expect(questsWithLocation.length).toBe(4); expect(questsWithoutLocation.length).toBe(1); expect(questsWithLocation[0].location_id).toBeDefined(); }); it('should grouped quests by location_id correctly', () => { const allQuests = [...mockQuestsRivendell, ...mockQuestsLothlorien, ...mockQuestsMoria]; const questsByLocation = allQuests.reduce((acc, quest) => { if (quest.location_id) { if (!acc[quest.location_id]) { acc[quest.location_id] = []; } acc[quest.location_id].push(quest); } return acc; }, {} as Record); expect(Object.keys(questsByLocation).length).toBe(3); expect(questsByLocation[1].length).toBe(3); expect(questsByLocation[2].length).toBe(2); expect(questsByLocation[3].length).toBe(1); }); }); describe('Quest Marker Validation', () => { it('should validate that all quests have location_id before clustering', () => { const allQuests = [...mockQuestsRivendell, ...mockQuestsNoLocation]; const invalidQuests = allQuests.filter(q => !q.location_id); expect(invalidQuests.length).toBe(1); expect(invalidQuests[0].title).toBe('Orphan Quest'); expect(invalidQuests[0].location_id).toBeUndefined(); }); it('should validate location coordinates exist for marker placement', () => { const validLocations = mockLocations.filter( loc => loc.map_x !== undefined && loc.map_y !== undefined ); expect(validLocations.length).toBe(mockLocations.length); validLocations.forEach(loc => { expect(loc.map_x).toBeDefined(); expect(loc.map_y).toBeDefined(); expect(typeof loc.map_x).toBe('number'); expect(typeof loc.map_y).toBe('number'); }); }); it('should handle quests with matching location_ids', () => { const questsByLocation = { 1: mockQuestsRivendell.filter(q => q.location_id === 1), 2: mockQuestsLothlorien.filter(q => q.location_id === 2) }; questsByLocation[1].forEach(quest => { expect(quest.location_id).toBe(1); }); questsByLocation[2].forEach(quest => { expect(quest.location_id).toBe(2); }); }); }); describe('Cluster Count Logic', () => { it('should calculate correct cluster sizes for small clusters', () => { const clusterSizes = { small: 1, // 1-9 quests medium: 10, // 10-49 quests large: 50 // 50+ quests }; const getClusterSize = (count: number) => { if (count < 10) return 'small'; if (count < 50) return 'medium'; return 'large'; }; expect(getClusterSize(1)).toBe('small'); expect(getClusterSize(5)).toBe('small'); expect(getClusterSize(10)).toBe('medium'); expect(getClusterSize(30)).toBe('medium'); expect(getClusterSize(50)).toBe('large'); expect(getClusterSize(100)).toBe('large'); }); it('should calculate correct total quest count across locations', () => { const allQuests = [...mockQuestsRivendell, ...mockQuestsLothlorien, ...mockQuestsMoria]; const questCountByStatus = { notBegun: allQuests.filter(q => q.status === 'not_yet_begun').length, inProgress: allQuests.filter(q => q.status === 'the_road_goes_ever_on').length, completed: allQuests.filter(q => q.status === 'it_is_done').length }; expect(questCountByStatus.notBegun).toBe(4); // Rivendell: 1, 2 | Lothlorien: 1, 2 = 4 total not begun expect(questCountByStatus.inProgress).toBe(2); // Rivendell: 3 (the_road_goes_ever_on), Moria: 20 (the_road_goes_ever_on) expect(questCountByStatus.completed).toBe(0); }); it('should identify dark magic quests for priority clustering', () => { const allQuests = [...mockQuestsRivendell, ...mockQuestsMoria]; const darkMagicQuests = allQuests.filter(q => q.is_dark_magic); expect(darkMagicQuests.length).toBe(1); expect(darkMagicQuests[0].id).toBe(20); expect(darkMagicQuests[0].title).toBe('Navigate the Depths'); }); }); describe('Quest Marker Display', () => { it('should display quest type icons correctly', () => { const iconMap: { [key: string]: string } = { 'The Journey': '🧭', 'The Battle': '⚔️', 'The Fellowship': '👥', 'The Ring': '💍', 'Dark Magic': '👁️' }; mockQuestsRivendell.forEach(quest => { const icon = iconMap[quest.quest_type || ''] || '📜'; expect(icon).toBeDefined(); }); }); it('should format quest status for display', () => { const statusMap: Record = { 'not_yet_begun': 'Not Yet Begun', 'the_road_goes_ever_on': 'The Road Goes Ever On...', 'it_is_done': 'It Is Done', 'the_shadow_falls': 'The Shadow Falls', 'pending': 'Not Yet Begun', 'in_progress': 'The Road Goes Ever On...', 'completed': 'It Is Done', 'blocked': 'The Shadow Falls' }; mockQuestsRivendell.forEach(quest => { const displayStatus = statusMap[quest.status] || quest.status; expect(displayStatus).toBeTruthy(); expect(statusMap['not_yet_begun']).toBe('Not Yet Begun'); }); }); it('should preserve quest selection state across re-renders', () => { const selectedQuestId = 1; const quests = mockQuestsRivendell; const isSelected = (questId: number) => questId === selectedQuestId; expect(isSelected(1)).toBe(true); expect(isSelected(2)).toBe(false); expect(isSelected(3)).toBe(false); }); }); describe('Marker Offset Calculation', () => { it('should calculate offsets for multiple markers at same location', () => { // Offset pattern for markers at the same location const getQuestOffset = (index: number, total: number): [number, number] => { const angleStep = (2 * Math.PI) / Math.max(total, 1); const angle = angleStep * index; const radius = total === 1 ? 0 : Math.max(15, total * 3); return [ Math.sin(angle) * radius / 100, Math.cos(angle) * radius / 100 ]; }; // Single marker should have no offset const [offsetY1, offsetX1] = getQuestOffset(0, 1); expect(offsetY1).toBe(0); expect(offsetX1).toBe(0); // Multiple markers should have circular offsets const [offsetY2, offsetX2] = getQuestOffset(0, 3); const [offsetY3, offsetX3] = getQuestOffset(1, 3); expect(Math.abs(offsetY2)).toBeLessThan(Math.abs(offsetX2)); }); }); describe('Quest Marker Performance', () => { it('should handle large numbers of quests efficiently', () => { // Create 100 quests distributed across 10 locations const largeQuestSet = Array.from({ length: 100 }, (_, i) => ({ id: i, title: `Quest ${i}`, description: 'Test quest', status: 'not_yet_begun', quest_type: 'The Journey', priority: 'Standard', location_id: (i % 10) + 1, is_dark_magic: false, created_at: '2024-01-01', updated_at: '2024-01-01' } as Quest)); const questsByLocation = largeQuestSet.reduce((acc, quest) => { if (quest.location_id) { if (!acc[quest.location_id]) { acc[quest.location_id] = []; } acc[quest.location_id].push(quest); } return acc; }, {} as Record); expect(Object.keys(questsByLocation).length).toBe(10); expect(questsByLocation[1].length).toBe(10); expect(Object.values(questsByLocation).flat().length).toBe(100); // Verify clustering would work const clusterCounts = Object.entries(questsByLocation).map( ([, quests]) => quests.length ); expect(clusterCounts.every(count => count === 10)).toBe(true); }); it('should handle empty quest arrays', () => { const emptyQuests: Quest[] = []; const questsByLocation = emptyQuests.reduce((acc, quest) => { if (quest.location_id) { if (!acc[quest.location_id]) { acc[quest.location_id] = []; } acc[quest.location_id].push(quest); } return acc; }, {} as Record); expect(Object.keys(questsByLocation).length).toBe(0); expect(Object.values(questsByLocation).flat().length).toBe(0); }); }); describe('Zoom and Aggregation Behavior', () => { it('should define clustering behavior for different zoom levels', () => { const clusterSettings = { maxClusterRadius: 40, minZoomLevel: -2, maxZoomLevel: 2, spiderfyOnMaxZoom: true, zoomToBoundsOnClick: true }; expect(clusterSettings.maxClusterRadius).toBe(40); expect(clusterSettings.minZoomLevel).toBeLessThan(clusterSettings.maxZoomLevel); expect(clusterSettings.spiderfyOnMaxZoom).toBe(true); }); it('should support spiderfy mode for high-zoom viewing', () => { // Spiderfy puts markers in a circle pattern when cluster is clicked at max zoom const questsByLocation = { 1: mockQuestsRivendell // 3 quests }; expect(questsByLocation[1].length).toBeGreaterThan(1); // These would be spidered out in a circle at max zoom }); }); });