Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions src/__spec__/protvista-uniprot-structure-empty.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

vi.mock('@nightingale-elements/nightingale-structure', () => {
class MockNightingaleStructure extends HTMLElement {}
return { default: MockNightingaleStructure };
});

vi.mock('../utils', async () => {
const actual =
await vi.importActual<typeof import('../utils')>('../utils');
return {
...actual,
fetchAll: vi.fn(),
};
});

import { fetchAll } from '../utils';
import '../protvista-uniprot-structure';
import type ProtvistaUniprotStructure from '../protvista-uniprot-structure';
import type { ProcessedStructureData } from '../protvista-uniprot-structure';

const mockedFetchAll = fetchAll as unknown as ReturnType<typeof vi.fn>;

const emptyFetchAll = async (urls: string[]) =>
Object.fromEntries(urls.map((u) => [u, null]));

const flushMicrotasks = async () => {
// Two passes is enough for fetchAll's resolution callback to run and
// for the subsequent dispatchEvent to land.
await Promise.resolve();
await Promise.resolve();
};

const waitForEventOrFlush = async (
el: HTMLElement,
listener: ReturnType<typeof vi.fn>
) => {
await flushMicrotasks();
// updateComplete is on LitElement; settle Lit's reactive cycle too.
await (el as unknown as { updateComplete: Promise<unknown> }).updateComplete;
await flushMicrotasks();
return listener;
};

const createElementWith = (
attrs: { accession?: string; checksum?: string; noTable?: boolean } = {}
) => {
const el = document.createElement(
'protvista-uniprot-structure'
) as ProtvistaUniprotStructure;
if (attrs.accession) el.setAttribute('accession', attrs.accession);
if (attrs.checksum) el.setAttribute('checksum', attrs.checksum);
if (attrs.noTable) el.setAttribute('no-table', '');
return el;
};

describe('<protvista-uniprot-structure> structures-loaded event', () => {
beforeEach(() => {
mockedFetchAll.mockReset();
});

afterEach(() => {
document.body.innerHTML = '';
});

it('dispatches structures-loaded with [] when the merged result is empty', async () => {
mockedFetchAll.mockImplementation(emptyFetchAll);

const el = createElementWith({ accession: 'P00000', noTable: true });
const listener = vi.fn();
el.addEventListener('structures-loaded', listener);
document.body.appendChild(el);

await waitForEventOrFlush(el, listener);

expect(listener).toHaveBeenCalledTimes(1);
const event = listener.mock
.calls[0][0] as CustomEvent<ReadonlyArray<ProcessedStructureData>>;
expect(Array.isArray(event.detail)).toBe(true);
expect(event.detail).toHaveLength(0);
expect(el.data).toEqual([]);
expect(el.selectedId).toBeUndefined();
expect(
(el as unknown as { loading?: boolean }).loading
).toBe(false);
});

it('dispatches structures-loaded exactly once for a non-empty payload', async () => {
mockedFetchAll.mockImplementation(async (urls: string[]) => {
const [pdbUrl, alphaFoldUrl, beaconsUrl] = urls;
return {
[pdbUrl]: {
uniProtKBCrossReferences: [
{
database: 'PDB',
id: '1ABC',
properties: [
{ key: 'Method', value: 'X-ray' },
{ key: 'Resolution', value: '2.0 A' },
{ key: 'Chains', value: 'A=1-100' },
],
},
],
},
[alphaFoldUrl]: null,
[beaconsUrl]: null,
};
});

const el = createElementWith({ accession: 'P00001', noTable: true });
const listener = vi.fn();
el.addEventListener('structures-loaded', listener);
document.body.appendChild(el);

await waitForEventOrFlush(el, listener);

expect(listener).toHaveBeenCalledTimes(1);
const event = listener.mock
.calls[0][0] as CustomEvent<ReadonlyArray<ProcessedStructureData>>;
expect(event.detail).toHaveLength(1);
expect(event.detail[0]).toMatchObject({ id: '1ABC', source: 'PDB' });
expect(el.selectedId).toBe('1ABC');
});

it('respects a consumer-preset selectedId when payload is empty', async () => {
mockedFetchAll.mockImplementation(emptyFetchAll);

const el = createElementWith({ accession: 'P00002', noTable: true });
el.selectedId = 'foo';
const listener = vi.fn();
el.addEventListener('structures-loaded', listener);
document.body.appendChild(el);

await waitForEventOrFlush(el, listener);

expect(listener).toHaveBeenCalledTimes(1);
expect(el.selectedId).toBe('foo');
});

it('does not dispatch when accession and checksum are both missing', async () => {
mockedFetchAll.mockImplementation(emptyFetchAll);

const el = createElementWith();
const listener = vi.fn();
el.addEventListener('structures-loaded', listener);
document.body.appendChild(el);

await waitForEventOrFlush(el, listener);

expect(listener).not.toHaveBeenCalled();
expect(mockedFetchAll).not.toHaveBeenCalled();
});

it('renders the "No structure information available" message for an empty payload with no-table absent', async () => {
mockedFetchAll.mockImplementation(emptyFetchAll);

const el = createElementWith({ accession: 'P00003' });
const listener = vi.fn();
el.addEventListener('structures-loaded', listener);
document.body.appendChild(el);

await waitForEventOrFlush(el, listener);

expect(el.textContent).toContain('No structure information available');
});

it('does not render the internal data table for an empty payload', async () => {
mockedFetchAll.mockImplementation(emptyFetchAll);

const el = createElementWith({ accession: 'P00004' });
const listener = vi.fn();
el.addEventListener('structures-loaded', listener);
document.body.appendChild(el);

await waitForEventOrFlush(el, listener);

expect(el.querySelector('protvista-uniprot-datatable')).toBeNull();
});
});
14 changes: 7 additions & 7 deletions src/adapters/feature-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ const transformData = (data: { features?: Record<string, unknown>[] }) => {
let transformedData: Record<string, unknown>[] = [];
const { features } = data;
if (features && features.length > 0) {
transformedData = features.map((feature) => {
return {
...feature,
tooltipContent: formatTooltip(feature),
};
});
transformedData = renameProperties(transformedData);
// Rename `begin` to `start` first so formatTooltip can read the unified
// field name regardless of the upstream wire format.
const renamed = renameProperties(features);
transformedData = renamed.map((feature) => ({
...feature,
tooltipContent: formatTooltip(feature),
}));
}
return transformedData;
};
Expand Down
9 changes: 5 additions & 4 deletions src/adapters/proteomics-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@ const proteomicsTrackProperties = (feature: ProteomicsFeature, taxId: number) =>
};

const transformData = (data: ProteomicsData) => {
let adaptedData: (ProteomicsFeature & { start?: number })[] = [];
let adaptedData: ProteomicsFeature[] = [];

if (data && data.features && data.features.length !== 0) {
adaptedData = data.features.map((feature) => {
// Rename `begin` to `start` first so formatTooltip (called inside
// proteomicsTrackProperties) can read the unified field name.
const renamed = renameProperties(data.features) as ProteomicsFeature[];
adaptedData = renamed.map((feature) => {
feature.residuesToHighlight = feature.ptms?.map((ptm) => ({
name: ptm.name,
position: ptm.position,
Expand All @@ -35,8 +38,6 @@ const transformData = (data: ProteomicsData) => {
proteomicsTrackProperties(feature, data.taxid)
);
});

adaptedData = renameProperties(adaptedData) as typeof adaptedData;
}
return adaptedData;
};
Expand Down
25 changes: 16 additions & 9 deletions src/adapters/variation-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,22 @@ const transformData = (
variants: TransformedVariant[];
} | null => {
const { sequence, features } = data;
const variants = features.map((variant) => ({
...variant,
accession: variant.genomicLocation?.join(', ') ?? '',
variant: variant.alternativeSequence || AminoAcid.Empty,
start: +variant.begin,
xrefNames: getSourceType(variant.xrefs, variant.sourceType),
hasPredictions: !!(variant.predictions && variant.predictions.length > 0),
tooltipContent: formatTooltip(variant),
}));
const variants = features.map((variant) => {
// Build the transformed shape first (with `start`) so formatTooltip can
// read the unified field name rather than the wire-format `begin`.
const transformed = {
...variant,
accession: variant.genomicLocation?.join(', ') ?? '',
variant: variant.alternativeSequence || AminoAcid.Empty,
start: +variant.begin,
xrefNames: getSourceType(variant.xrefs, variant.sourceType),
hasPredictions: !!(variant.predictions && variant.predictions.length > 0),
};
return {
...transformed,
tooltipContent: formatTooltip(transformed),
};
});
if (!variants) return null;
return { sequence, variants };
};
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ export { default as getFeatureTooltip } from './tooltips/feature-tooltip';
export { default as getStructureTooltip } from './tooltips/structure-tooltip';
export { default as getVariationTooltip } from './tooltips/variation-tooltip';
export { default as ProtvistaUniprotStructure } from './protvista-uniprot-structure';
export type { ProcessedStructureData } from './protvista-uniprot-structure';
import ProtvistaUniprot from './protvista-uniprot';
export default ProtvistaUniprot;
2 changes: 2 additions & 0 deletions src/protvista-uniprot-datatable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,3 +487,5 @@ declare global {
>;
}
}

export default ProtvistaUniprotDatatable;
Loading
Loading