diff --git a/src/datasource/wikitree_api.spec.ts b/src/datasource/wikitree_api.spec.ts new file mode 100644 index 0000000..9443869 --- /dev/null +++ b/src/datasource/wikitree_api.spec.ts @@ -0,0 +1,132 @@ +import {beforeEach, describe, expect, it, jest} from '@jest/globals'; +import {getPeople, getRelatives as getRelativesApi, Person} from 'wikitree-js'; +import {mockSessionStorage} from './test_helpers'; +import {loadData} from './wikitree_api'; + +// 1. Service Mocking (Recommended): Mock the 'wikitree-js' module +jest.mock('wikitree-js', () => ({ + clientLogin: jest.fn(), + getLoggedInUserName: jest.fn(), + getPeople: jest.fn(), + getRelatives: jest.fn(), +})); + +describe('WikiTree API DataSource', () => { + let sessionStorageMock: {[key: string]: string}; + + const mockSmith = { + Id: 123, + Name: 'Smith-123', + FirstName: 'John', + LastNameAtBirth: 'Smith', + Gender: 'Male', + Father: 0, + Mother: 0, + Spouses: {}, + Children: {}, + } as unknown as Person; + + beforeEach(() => { + sessionStorageMock = mockSessionStorage(); + jest.clearAllMocks(); + + global.window = { + location: { + hostname: 'localhost', + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + }); + + describe('loadData caching', () => { + it('caches data and avoids redundant API calls for the same ID', async () => { + // Mock wikitree-js getRelatives (which is imported as getRelativesApi in wikitree_api.ts) + const mockGetRelatives = jest.mocked(getRelativesApi); + mockGetRelatives.mockResolvedValue([mockSmith]); + + // Mock wikitree-js getPeople (for getAncestors call) + const mockGetPeople = jest.mocked(getPeople); + mockGetPeople.mockResolvedValue([mockSmith]); + + // 1. Initial load + const result1 = await loadData('Smith-123'); + + expect(result1.length).toBe(1); + expect(result1[0].Name).toBe('Smith-123'); + + // The API should be called once for first person and once for ancestors + expect(mockGetRelatives).toHaveBeenCalledTimes(1); + expect(mockGetRelatives).toHaveBeenCalledWith( + ['Smith-123'], + expect.any(Object), + expect.any(Object), + ); + expect(mockGetPeople).toHaveBeenCalledTimes(1); + expect(mockGetPeople).toHaveBeenCalledWith( + ['Smith-123'], + expect.any(Object), + expect.any(Object), + ); + + // Verify the sessionStorage has the keys cached + expect(sessionStorageMock['wikitree:relatives:Smith-123']).toBeDefined(); + expect(sessionStorageMock['wikitree:ancestors:Smith-123']).toBeDefined(); + + // Reset mock call counts to verify cache hits + mockGetRelatives.mockClear(); + mockGetPeople.mockClear(); + + // 2. Second load with exact same key + const result2 = await loadData('Smith-123'); + + expect(result2.length).toBe(1); + expect(result2[0].Name).toBe('Smith-123'); + + // It should load entirely from cache and make ZERO API calls! + expect(mockGetRelatives).not.toHaveBeenCalled(); + expect(mockGetPeople).not.toHaveBeenCalled(); + }); + }); + + // 2. HTTP Fetch Mocking (Alternative approach shown for demonstration) + describe('HTTP Fetch Mocking Demo', () => { + let originalFetch: typeof global.fetch; + + beforeEach(() => { + originalFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it('demonstrates mocking window.fetch directly', async () => { + // Restore wikitree-js getRelatives mock to actual implementation + // so it calls fetch (if it runs in node, we mock fetch globally) + const mockGetRelatives = jest.mocked(getRelativesApi); + mockGetRelatives.mockImplementation(async (keys, fields, options) => { + // Simple mock implementation of what wikitree-js wikitree API wrapper does: + // It performs a fetch to options.apiUrl or default API url. + const url = options?.apiUrl || 'https://api.wikitree.com/api.php'; + const res = await fetch(url); + return (await res.json()) as Person[]; + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mockFetch = jest.fn<() => Promise>().mockResolvedValue({ + status: 200, + json: async () => [mockSmith], + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + global.fetch = mockFetch as any; + + const mockGetPeople = jest.mocked(getPeople); + mockGetPeople.mockResolvedValue([mockSmith]); + + // Call loadData which will execute our mock implementation calling fetch + await loadData('smith-123'); + + expect(mockFetch).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/datasource/wikitree_api.ts b/src/datasource/wikitree_api.ts index 3591254..086a2e0 100644 --- a/src/datasource/wikitree_api.ts +++ b/src/datasource/wikitree_api.ts @@ -83,7 +83,16 @@ async function getRelatives( ): Promise { const result: Person[] = []; const keysToFetch: string[] = []; + + // Deduplicate keys + const uniqueKeys: string[] = []; keys.forEach((key) => { + if (!uniqueKeys.includes(key)) { + uniqueKeys.push(key); + } + }); + + uniqueKeys.forEach((key) => { const cachedData = getCacheItem(`wikitree:relatives:${key}`); if (cachedData) { result.push(cachedData); @@ -110,6 +119,7 @@ async function getRelatives( response.forEach((person) => { setCacheItem(`wikitree:relatives:${person.Name}`, person); }); + return result.concat(response); } @@ -143,14 +153,31 @@ function getSpouseKeys(person: Person) { } async function getAllAncestors(keys: string[], handleCors: boolean) { + // Deduplicate input keys + const uniqueKeys: string[] = []; + keys.forEach((k) => { + if (!uniqueKeys.includes(k)) { + uniqueKeys.push(k); + } + }); + const ancestors = await Promise.all( - keys.map((key) => getAncestors(key, handleCors)), + uniqueKeys.map((key) => getAncestors(key, handleCors)), ); const ancestorKeys = ancestors .flat() .map((person) => person.Name) .filter((key) => !!key); - const ancestorDetails = await getRelatives(ancestorKeys, handleCors); + + // Deduplicate ancestorKeys as well before calling getRelatives + const uniqueAncestorKeys: string[] = []; + ancestorKeys.forEach((k) => { + if (!uniqueAncestorKeys.includes(k)) { + uniqueAncestorKeys.push(k); + } + }); + + const ancestorDetails = await getRelatives(uniqueAncestorKeys, handleCors); // Map from person id to father id if the father profile is private. const privateFathers: Map = new Map(); @@ -210,10 +237,22 @@ async function getAllDescendants(key: string, handleCors: boolean) { Object.values(person.Spouses || {}), ); everyone.push(...allSpouses); + // Fetch all children. - toFetch = people.flatMap((person) => + const nextToFetch = people.flatMap((person) => Object.values(person.Children || {}).map((c) => c.Name), ); + // Deduplicate nextToFetch and filter out already fetched people + const uniqueNextToFetch: string[] = []; + nextToFetch.forEach((name) => { + if ( + !uniqueNextToFetch.includes(name) && + !everyone.some((p) => p.Name === name) + ) { + uniqueNextToFetch.push(name); + } + }); + toFetch = uniqueNextToFetch; generation++; } return everyone; diff --git a/src/pages/view_page.tsx b/src/pages/view_page.tsx index 8c05114..73813f8 100644 --- a/src/pages/view_page.tsx +++ b/src/pages/view_page.tsx @@ -292,13 +292,16 @@ export function ViewPage() { } if ( - state === AppState.INITIAL || - isNewData(args.sourceSpec, args.selection) + (state === AppState.INITIAL || + isNewData(args.sourceSpec, args.selection)) && + state !== AppState.LOADING && + state !== AppState.LOADING_MORE ) { // Set loading state. setState(AppState.LOADING); // Set state from URL parameters. setSourceSpec(args.sourceSpec); + loadedSelectionRef.current = args.selection; const currentFetchId = ++fetchIdRef.current; setLoadingStatus('Loading…'); try { @@ -343,8 +346,9 @@ export function ViewPage() { // Update selection if it has changed in the URL. const loadMoreFromWikitree = args.sourceSpec.source === DataSourceEnum.WIKITREE && + !!args.selection && (!loadedSelectionRef.current || - loadedSelectionRef.current.id !== args.selection?.id); + loadedSelectionRef.current.id !== args.selection.id); setState( loadMoreFromWikitree ? AppState.LOADING_MORE : AppState.SHOWING_CHART, ); diff --git a/tests/wikitree_view.spec.ts b/tests/wikitree_view.spec.ts new file mode 100644 index 0000000..8f028e8 --- /dev/null +++ b/tests/wikitree_view.spec.ts @@ -0,0 +1,170 @@ +import {expect, test} from '@playwright/test'; + +// Mock data +// Mock data - Individual basic properties (returned by getPeople) +const MOCK_PERSON_SMITH = { + Id: 123, + Name: 'Smith-123', + FirstName: 'John', + LastNameAtBirth: 'Smith', + LastNameCurrent: 'Smith', + LastNameOther: 'Unknown', + Gender: 'Male', + Father: 100, + Mother: 0, +}; + +const MOCK_PERSON_FATHER = { + Id: 100, + Name: 'Smith-100', + FirstName: 'Bonifacy', + LastNameAtBirth: 'Smith', + LastNameCurrent: 'Smith', + LastNameOther: 'Unknown', + Gender: 'Male', + Father: 0, + Mother: 0, +}; + +const MOCK_PERSON_SPOUSE = { + Id: 10, + Name: 'Doe-10', + FirstName: 'Jane', + LastNameAtBirth: 'Doe', + LastNameCurrent: 'Smith', + LastNameOther: 'Unknown', + Gender: 'Female', + Father: 0, + Mother: 0, +}; + +const MOCK_PERSON_CHILD = { + Id: 124, + Name: 'Smith-124', + FirstName: 'Radobod', + LastNameAtBirth: 'Smith', + LastNameCurrent: 'Smith', + LastNameOther: 'Unknown', + Gender: 'Male', + Father: 123, + Mother: 10, +}; + +// Full objects returned by getRelatives (with Spouses/Children lists) +const MOCK_SMITH = { + ...MOCK_PERSON_SMITH, + Spouses: { + '10': MOCK_PERSON_SPOUSE, + }, + Children: { + '124': MOCK_PERSON_CHILD, + }, +}; + +const MOCK_FATHER = { + ...MOCK_PERSON_FATHER, + Spouses: {}, + Children: {}, +}; + +const MOCK_SPOUSE = { + ...MOCK_PERSON_SPOUSE, + Spouses: {}, + Children: {}, +}; + +const MOCK_CHILD = { + ...MOCK_PERSON_CHILD, + Spouses: {}, + Children: {}, +}; + +test.describe('WikiTree View', () => { + test.beforeEach(async ({context, page}) => { + // Block external trackers + await context.route('**/*google-analytics.com/**', (route) => + route.abort(), + ); + await context.route('**/*googletagmanager.com/**', (route) => + route.abort(), + ); + + // Intercept WikiTree API requests + await context.route('**/api.php*', async (route) => { + const request = route.request(); + const postData = request.postData() || ''; + + const actionMatch = postData.match(/name="action"\r?\n\r?\n([^\r\n]+)/); + const action = actionMatch ? actionMatch[1] : ''; + + const keysMatch = postData.match(/name="keys"\r?\n\r?\n([^\r\n]+)/); + const keys = keysMatch ? keysMatch[1].split(',') : []; + + if (action === 'getRelatives') { + const items = keys + .map((k) => { + if (k === 'Smith-123') return {person: MOCK_SMITH}; + if (k === 'Smith-100') return {person: MOCK_FATHER}; + if (k === 'Doe-10') return {person: MOCK_SPOUSE}; + if (k === 'Smith-124') return {person: MOCK_CHILD}; + return null; + }) + .filter(Boolean); + + await route.fulfill({ + status: 200, + contentType: 'application/json', + headers: {'Access-Control-Allow-Origin': '*'}, + body: JSON.stringify([{items}]), + }); + } else if (action === 'getPeople') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let people: Record = {}; + if (keys.includes('Smith-123')) { + people = { + 'Smith-123': MOCK_PERSON_SMITH, + 'Smith-100': MOCK_PERSON_FATHER, + }; + } else if (keys.includes('Doe-10')) { + people = { + 'Doe-10': MOCK_PERSON_SPOUSE, + }; + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + headers: {'Access-Control-Allow-Origin': '*'}, + body: JSON.stringify([{people}]), + }); + } else { + await route.continue(); + } + }); + + await page.goto('/#/view?indi=Smith-123&source=wikitree&standalone=true'); + }); + + test('loads data from WikiTree API', async ({page}) => { + // Verify initial load displays John Smith + await expect(page.locator('#content')).toContainText('John'); + await expect(page.locator('#content')).toContainText('Smith'); + }); + + test('shows ancestors (Bonifacy)', async ({page}) => { + await expect(page.locator('#content')).toContainText('Bonifacy'); + }); + + test('shows spouses (Jane)', async ({page}) => { + await expect(page.locator('#content')).toContainText('Jane'); + await expect(page.locator('#content')).toContainText('Doe'); + }); + + test('shows descendants (Radobod) on selection click', async ({page}) => { + // Children are loaded when clicking the node, or directly if loaded by default. + // In WikiTree wrapper logic, loadData calls: + // const allDescendants = getAllDescendants(key, handleCors); + // So Radobod should be loaded initially and displayed. + await expect(page.locator('#content')).toContainText('Radobod'); + }); +});