auspice
Version:
Web app for visualizing pathogen evolution
269 lines (237 loc) • 11.6 kB
text/typescript
/**
* @jest-environment jsdom
*/
/* NOTE: We use the jsdom environment since `createStateFromQueryOrJSONs` relies on `window` */
/* Allow assertions here to simplify things - if we get them wrong the tests will flag them up */
/* eslint-disable @typescript-eslint/consistent-type-assertions */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { configureStore, EnhancedStore } from '@reduxjs/toolkit';
import rootReducer from "../src/reducers";
import { getDefaultControlsState } from "../src/reducers/controls";
import { createStateFromQueryOrJSONs } from "../src/actions/recomputeReduxState";
import { CLEAN_START } from "../src/actions/types";
import { createDatasetJson } from "../src/util/constructDatasetJson";
import type { RootState } from "../src/store";
import genericJson from './data/generic.json';
import complexAnnotationJson from './data/test_complex-genome-annotation.json';
/* P.S. provision these (non-git-committed) files via `npm run fetch-test-data` */
// @ts-ignore This will fail type-check if the JSONs haven't been fetched
import ebov2013 from './fetched-jsons/ebola-ebov-2013.json';
// @ts-ignore This will fail type-check if the JSONs haven't been fetched
import ebovAllOutbreaks from './fetched-jsons/ebola-all-outbreaks.json';
/* Modify global types to specify our custom match functions */
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace jest {
interface Matchers<R> {
toMatchAnnotation(expected: unknown): R;
toEqualDisplayDefault(expected: unknown): R;
}
}
}
function clone<T>(obj: T): T { return JSON.parse(JSON.stringify(obj)); }
/**
* Create a redux store and load data into it via CLEAN_START, mirroring the SPA.
*/
function createTestStore(json: unknown): EnhancedStore<RootState> {
const store = configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({ serializableCheck: false, immutableCheck: false }),
});
const cleanStartState = createStateFromQueryOrJSONs({
json: clone(json) as any, query: {}, dispatch: store.dispatch,
});
store.dispatch({ type: CLEAN_START, ...cleanStartState });
return store as EnhancedStore<RootState>;
}
const datasets: [string, Record<string, unknown>][] = [
["generic.json", genericJson as Record<string, unknown>],
["complexAnnotationJson", complexAnnotationJson as Record<string, unknown>],
["West African Ebola Outbreak (EBOV-2013)", ebov2013 as Record<string, unknown>],
["All EBOV outbreaks", ebovAllOutbreaks as Record<string, unknown>],
];
describe.each(datasets)("\n\n%s round-trip", (_name, originalJson) => {
let store: EnhancedStore<RootState>;
let reconstructedJson: ReturnType<typeof createDatasetJson>;
beforeAll(() => {
jest.spyOn(console, 'error').mockImplementation(() => {});
store = createTestStore(originalJson);
reconstructedJson = createDatasetJson(store.getState);
});
afterAll(() => {
jest.restoreAllMocks();
});
it("JSON top-level keys are the same (tree, meta etc)", () => {
const originalKeys = new Set(Object.keys(originalJson));
expect(originalKeys).toEqual(new Set(Object.keys(reconstructedJson)));
expect(originalKeys).toContain('tree');
expect(originalKeys).toContain('meta');
});
describe("JSON.meta", () => {
const meta = originalJson.meta as Record<string, unknown>;
const NON_ROUND_TRIPPED_KEYS = [
'extensions', // data structures aren't stored in redux state and thus aren't possible to round-trip (We could special case Nextclade in the future)
];
it("JSON.meta keys are the same", () => {
const originalKeys = new Set(
[...Object.keys(originalJson.meta as Record<string, unknown>)].filter((k) => !NON_ROUND_TRIPPED_KEYS.includes(k))
);
const reconstructedKeys = new Set(Object.keys(reconstructedJson.meta || {}));
reconstructedKeys.delete('extensions'); // Auspice always uses this, but it may not be in the original JSON
expect(originalKeys).toEqual(reconstructedKeys);
});
/**
* Partition the meta properties on the (original) JSON according to how we test them
*/
const scalarKeys: (keyof typeof meta)[] = [];
const compareByEqualityKeys: (keyof typeof meta)[] = [];
for (const key of Object.keys(meta)) {
if (NON_ROUND_TRIPPED_KEYS.includes(key)) continue;
if (['title', 'updated', 'version', 'warning', 'description', 'build_url', 'build_avatar'].includes(key)) {
scalarKeys.push(key);
} else if (['display_defaults', 'genome_annotations', 'colorings'].includes(key)) {
// no-op: handled as special-cases below
} else {
compareByEqualityKeys.push(key);
}
}
it("JSON.meta.X = <scalar> values are the same", () => {
for (const key of scalarKeys) {
if (Object.hasOwn(meta, key)) {
expect(meta[key]).toEqual(reconstructedJson.meta[key])
}
}
});
/* Directly compare most of the (non-scalar) .meta data structures using individual `it` calls to make failures easier to understand */
const originalMeta = originalJson.meta as Record<string, unknown>;
for (const key of compareByEqualityKeys) {
it(`JSON.meta.${key} values are the same`, () => {
expect(originalMeta[key]).toEqual(reconstructedJson.meta[key])
});
}
const display_defauts = originalMeta.display_defaults as Record<string, unknown>;
if (display_defauts) {
for (const key of Object.keys(display_defauts)) {
it(`JSON.meta.display_defaults.${key} values are the same`, () => {
expect(display_defauts[key]).toEqualDisplayDefault([key, reconstructedJson.meta.display_defaults[key]])
});
}
}
it(`JSON.meta.colorings values are the same`, () => compareColorings(originalMeta.colorings, reconstructedJson.meta.colorings));
if (originalMeta.genome_annotations as Record<string, unknown>) {
it(`JSON.meta.genome_annotations values are the same`, () => {
expect(originalMeta.genome_annotations).toMatchAnnotation(reconstructedJson.meta.genome_annotations)
});
}
it("JSON.root_sequence structures are the same (if applicable)", () => {
if (!originalJson.root_sequence) return;
expect(originalJson.root_sequence).toEqual(reconstructedJson.root_sequence);
});
});
describe("JSON.tree", () => {
it("tree topology (structure and node names) is the same", () => {
type SimpleTreeNode = { name?: string; children?: SimpleTreeNode[] };
const queue: [SimpleTreeNode, SimpleTreeNode, string][] = [
[originalJson.tree as SimpleTreeNode, reconstructedJson.tree as SimpleTreeNode, "root"],
];
while (queue.length > 0) {
const [a, b, path] = queue.pop()!;
expect(a.name).toEqual(b.name);
const aChildren = a.children ?? [];
const bChildren = b.children ?? [];
expect(aChildren.length).toEqual(bChildren.length);
for (let i = 0; i < aChildren.length; i++) {
queue.push([aChildren[i], bChildren[i], `${path}->${aChildren[i].name}`]);
}
}
});
it("per-node data (node_attrs, branch_attrs) is the same", () => {
type TreeNode = { name?: string; children?: TreeNode[]; node_attrs?: unknown; branch_attrs?: unknown };
type NodeMap = Map<string, { node_attrs?: unknown; branch_attrs?: unknown }>
/** Flatten tree structure to a Map of node name -> node data */
const collect = (node: TreeNode, map: NodeMap): NodeMap => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { children: _, name, ...rest } = node;
expect(map).not.toContain(name);
map.set(name!, rest);
for (const child of node.children ?? []) {
collect(child, map);
}
return map;
};
const originalNodes = collect(originalJson.tree as TreeNode, new Map());
const reconstructedNodes = collect(reconstructedJson.tree as TreeNode, new Map());
expect(originalNodes.size).toEqual(reconstructedNodes.size);
for (const [name, originalData] of originalNodes) {
expect(reconstructedNodes.has(name)).toBe(true);
expect(originalData).toEqual(reconstructedNodes.get(name)!);
}
});
});
});
function compareColorings(original: any, reconstructed: any):void {
if (original === undefined && reconstructed === undefined) return;
if (original === undefined && reconstructed) {
throw new Error(`No coloring data in original JSON but coloring in reconstructed JSON:\n${JSON.stringify(reconstructed)}`);
}
if (original && reconstructed === undefined ) {
throw new Error(`Coloring data in original JSON but no coloring data in reconstructed JSON. Original was:\n${JSON.stringify(original)}`);
}
// genotype is automatically injected by Auspice at load-time (if supported)
const og = original.filter((el) => el.key !== 'gt');
expect(og.map((el) => el.key)).toEqual(reconstructed.map((el) => el.key));
expect(og).toEqual(reconstructed);
}
expect.extend({
toMatchAnnotation(received, expected) {
const { gene: gene_a, strain: strain_a, color: color_a, display_name: display_name_a, description: description_a } = received;
const { gene: gene_b, strain: strain_b, color: color_b, display_name: display_name_b, description: description_b } = expected;
const pass = (
gene_a === gene_b &&
strain_a === strain_b &&
display_name_a === display_name_b &&
description_a === description_b &&
// only compare colors if provided in the original JSON, as the exported JSON will contain
// dynamically-generated colors if they weren't provided
(color_a === undefined || color_a !== color_b)
);
return {
pass,
message: (): string => `Annotations blocks differed (using custom comparison)`
};
},
});
expect.extend({
// received: original JSON, expected: reconstructed JSON
toEqualDisplayDefault(received, [key, expected]) {
if (received === expected || JSON.stringify(received) === JSON.stringify(expected)) {
return { pass: true, message: (): string => '' }
}
// If the originally specified default matched Auspice's starting defaults,
// then the JSON behaves the same way without the default being present. Thus we recreate
// a JSON without it.
if (received && expected === undefined) {
const defaultControls = getDefaultControlsState();
if (
(key === 'distance_measure' && defaultControls.distanceMeasure === received) ||
(key === 'layout' && defaultControls.layout === received) ||
(key === 'geo_resolution' && defaultControls.geoResolution === received) ||
(key === 'color_by' && defaultControls.colorBy === received) ||
(key === 'geo_resolution' && defaultControls.geoResolution === received) ||
(key === 'branch_label' && defaultControls.selectedBranchLabel === received) ||
(key === 'tip_label' && defaultControls.tipLabelKey === received) ||
(key === 'transmission_lines' && defaultControls.showTransmissionLines === received) ||
(key === 'language' && received==='en') // hardcoded default in Auspice code
) {
return { pass: true, message: (): string => '' }
}
}
return {
pass: false,
message: (): string => "Display defaults did not agree (after comparison with Auspice's defaults)." +
`Original JSON: ${ received }, reconstructed JSON: ${ expected }`
}
},
});