WikiTree loading improvements

This commit is contained in:
Przemek Więch
2026-06-15 07:39:22 +02:00
parent f887f40321
commit 17dd3ab291
4 changed files with 351 additions and 6 deletions

View File

@@ -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<any>>().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();
});
});
});

View File

@@ -83,7 +83,16 @@ async function getRelatives(
): Promise<Person[]> { ): Promise<Person[]> {
const result: Person[] = []; const result: Person[] = [];
const keysToFetch: string[] = []; const keysToFetch: string[] = [];
// Deduplicate keys
const uniqueKeys: string[] = [];
keys.forEach((key) => { keys.forEach((key) => {
if (!uniqueKeys.includes(key)) {
uniqueKeys.push(key);
}
});
uniqueKeys.forEach((key) => {
const cachedData = getCacheItem<Person>(`wikitree:relatives:${key}`); const cachedData = getCacheItem<Person>(`wikitree:relatives:${key}`);
if (cachedData) { if (cachedData) {
result.push(cachedData); result.push(cachedData);
@@ -110,6 +119,7 @@ async function getRelatives(
response.forEach((person) => { response.forEach((person) => {
setCacheItem(`wikitree:relatives:${person.Name}`, person); setCacheItem(`wikitree:relatives:${person.Name}`, person);
}); });
return result.concat(response); return result.concat(response);
} }
@@ -143,14 +153,31 @@ function getSpouseKeys(person: Person) {
} }
async function getAllAncestors(keys: string[], handleCors: boolean) { 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( const ancestors = await Promise.all(
keys.map((key) => getAncestors(key, handleCors)), uniqueKeys.map((key) => getAncestors(key, handleCors)),
); );
const ancestorKeys = ancestors const ancestorKeys = ancestors
.flat() .flat()
.map((person) => person.Name) .map((person) => person.Name)
.filter((key) => !!key); .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. // Map from person id to father id if the father profile is private.
const privateFathers: Map<number, number> = new Map(); const privateFathers: Map<number, number> = new Map();
@@ -210,10 +237,22 @@ async function getAllDescendants(key: string, handleCors: boolean) {
Object.values(person.Spouses || {}), Object.values(person.Spouses || {}),
); );
everyone.push(...allSpouses); everyone.push(...allSpouses);
// Fetch all children. // Fetch all children.
toFetch = people.flatMap((person) => const nextToFetch = people.flatMap((person) =>
Object.values(person.Children || {}).map((c) => c.Name), 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++; generation++;
} }
return everyone; return everyone;

View File

@@ -292,13 +292,16 @@ export function ViewPage() {
} }
if ( if (
state === AppState.INITIAL || (state === AppState.INITIAL ||
isNewData(args.sourceSpec, args.selection) isNewData(args.sourceSpec, args.selection)) &&
state !== AppState.LOADING &&
state !== AppState.LOADING_MORE
) { ) {
// Set loading state. // Set loading state.
setState(AppState.LOADING); setState(AppState.LOADING);
// Set state from URL parameters. // Set state from URL parameters.
setSourceSpec(args.sourceSpec); setSourceSpec(args.sourceSpec);
loadedSelectionRef.current = args.selection;
const currentFetchId = ++fetchIdRef.current; const currentFetchId = ++fetchIdRef.current;
setLoadingStatus('Loading…'); setLoadingStatus('Loading…');
try { try {
@@ -343,8 +346,9 @@ export function ViewPage() {
// Update selection if it has changed in the URL. // Update selection if it has changed in the URL.
const loadMoreFromWikitree = const loadMoreFromWikitree =
args.sourceSpec.source === DataSourceEnum.WIKITREE && args.sourceSpec.source === DataSourceEnum.WIKITREE &&
!!args.selection &&
(!loadedSelectionRef.current || (!loadedSelectionRef.current ||
loadedSelectionRef.current.id !== args.selection?.id); loadedSelectionRef.current.id !== args.selection.id);
setState( setState(
loadMoreFromWikitree ? AppState.LOADING_MORE : AppState.SHOWING_CHART, loadMoreFromWikitree ? AppState.LOADING_MORE : AppState.SHOWING_CHART,
); );

170
tests/wikitree_view.spec.ts Normal file
View File

@@ -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<string, any> = {};
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');
});
});