lotr-sut/sut/frontend/test/components/MiddleEarthMap.test.tsx
Fellowship Scholar f6a5823439 init commit
2026-03-29 20:07:56 +00:00

426 lines
14 KiB
TypeScript

/**
* 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<number, Quest[]>);
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<string, string> = {
'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<number, Quest[]>);
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<number, Quest[]>);
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
});
});
});