mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-07-17 17:21:48 +00:00
WikiTree loading improvements
This commit is contained in:
132
src/datasource/wikitree_api.spec.ts
Normal file
132
src/datasource/wikitree_api.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
170
tests/wikitree_view.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user