Skip to content
Open
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
78 changes: 78 additions & 0 deletions cypress/e2e/search-sort.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/// <reference types="cypress" />

// Covers only what a component test cannot: that the PrimeNG overlay opens,
// an option is clickable, and picking one actually re-orders the rendered
// cards. The ordering rules themselves are covered in
// src/app/browser/pages/search-results/search-results.component.spec.ts.
//
// The search API is stubbed so this spec is deterministic and does not depend
// on ontology.jax.org being up or returning a stable result set.
describe('Search results sorting', () => {
const TERMS = {
terms: [
{ id: 'HP:0000010', name: 'Bravo phenotype', definition: 'b', synonyms: [] },
{ id: 'HP:0000002', name: 'zulu phenotype', definition: 'z', synonyms: [] },
{ id: 'HP:0000100', name: 'Alpha phenotype', definition: 'a', synonyms: [] },
],
};

const RELEVANCE_ORDER = ['Bravo phenotype', 'zulu phenotype', 'Alpha phenotype'];

// One title anchor per result card - the component tag anchors the selector,
// so Tailwind class churn on the card doesn't break this spec.
const resultTitles = () =>
cy.get('app-search-result-card a').then(($els) => Cypress._.map($els, (el) => el.innerText.trim()));

// There are two p-selects on this page - the sort menu and the paginator's
// rows-per-page menu - so the paginator's has to be excluded explicitly.
const sortSelect = () => cy.get('p-select').not('p-paginator p-select');

const chooseSort = (label: string) => {
sortSelect().click();
cy.contains('.p-select-option', label).click();
// The overlay closes on select; wait for it so the next assertion isn't
// reading the list through a still-open panel.
cy.get('.p-select-overlay').should('not.exist');
};

beforeEach(() => {
cy.intercept('GET', '**/hp/search?*', { statusCode: 200, body: TERMS }).as('searchTerms');
cy.intercept('GET', '**/network/search/gene*', {
statusCode: 200,
body: { results: [], totalCount: 0 },
}).as('searchGenes');
cy.intercept('GET', '**/network/search/disease*', {
statusCode: 200,
body: { results: [], totalCount: 0 },
}).as('searchDiseases');

cy.visit('/search?q=phenotype&navFilter=term');
cy.wait(['@searchTerms', '@searchGenes', '@searchDiseases']);
});

it('defaults to Most Relevant and renders results in API order', () => {
sortSelect().should('contain.text', 'Most Relevant');
resultTitles().should('deep.equal', RELEVANCE_ORDER);
});

it('re-orders the rendered cards when a sort option is picked', () => {
chooseSort('Name (A-Z)');

sortSelect().should('contain.text', 'Name (A-Z)');
resultTitles().should('deep.equal', ['Alpha phenotype', 'Bravo phenotype', 'zulu phenotype']);
});

it('sorts by identifier and returns to API order via Most Relevant', () => {
chooseSort('Identifier (A-Z)');
resultTitles().should('deep.equal', ['zulu phenotype', 'Bravo phenotype', 'Alpha phenotype']);

chooseSort('Most Relevant');
resultTitles().should('deep.equal', RELEVANCE_ORDER);
});

it('exposes every sort option in the menu', () => {
sortSelect().click();
cy.get('.p-select-option').should('have.length', 5);
cy.get('.p-select-option').last().should('contain.text', 'Name (Z-A)');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ <h2 class="text-xl font-bold leading-7 text-[#222] m-0">Search Results for "{{ q
[options]="sortOptions"
[(ngModel)]="sortBy"
optionLabel="label"
(onChange)="onSortChange()"
scrollHeight=""
styleClass="text-xs font-semibold [--p-select-padding-x:8px] [--p-select-padding-y:6px] max-md:[--p-select-border-color:transparent] max-md:[--p-select-background:transparent] max-md:[--p-select-color:#0177b2] max-md:[--p-select-dropdown-color:#0177b2] max-md:shadow-none md:text-base md:font-normal md:[--p-select-padding-x:12px] md:[--p-select-padding-y:8px]"
/>
Expand Down
152 changes: 152 additions & 0 deletions src/app/browser/pages/search-results/search-results.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ActivatedRoute, provideRouter } from '@angular/router';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { Observable, of } from 'rxjs';

import { SearchResultsComponent } from './search-results.component';
import { SearchService } from '../../../shared/search/service/search.service';

// Deliberately NOT in any sorted order, so "Most Relevant" (= API order) is
// distinguishable from every other option.
const TERMS = [
{ id: 'HP:0000010', name: 'Bravo phenotype', definition: 'b', synonyms: [] },
{ id: 'HP:0000002', name: 'zulu phenotype', definition: 'z', synonyms: [] },
{ id: 'HP:0000100', name: 'Alpha phenotype', definition: 'a', synonyms: [] },
];

// Gene identifiers are not zero-padded, so they are what actually exercises
// numeric collation - lexicographically "NCBIGene:10" sorts before "NCBIGene:2".
const GENES = [
{ id: 'NCBIGene:10', name: 'NAT2' },
{ id: 'NCBIGene:100', name: 'ADA' },
{ id: 'NCBIGene:2', name: 'A2M' },
];

const DISEASES = [
{ id: 'OMIM:154700', name: 'Marfan syndrome' },
{ id: 'OMIM:100100', name: 'Prune belly syndrome' },
];

class SearchServiceStub {
searchAll(): Observable<unknown> {
return of({
terms: { terms: TERMS.map((t) => ({ ...t })) },
genes: { results: GENES.map((g) => ({ ...g })), totalCount: GENES.length },
diseases: { results: DISEASES.map((d) => ({ ...d })), totalCount: DISEASES.length },
});
}
}

describe('SearchResultsComponent sorting', () => {
let component: SearchResultsComponent;
let fixture: ComponentFixture<SearchResultsComponent>;

const selectSort = (value: string): void => {
const option = component.sortOptions.find((o) => o.value === value);
expect(option).toBeDefined();
component.sortBy = option;
component.onSortChange();
};

const titles = (): string[] => component.sortedItems.map((i) => i.title);
const ids = (): string[] => component.sortedItems.map((i) => i.subtitle);

beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [SearchResultsComponent, NoopAnimationsModule],
providers: [
provideRouter([]),
{ provide: SearchService, useClass: SearchServiceStub },
{
provide: ActivatedRoute,
useValue: { queryParams: of({ q: 'phenotype', navFilter: 'term' }) },
},
],
}).compileComponents();
}));

beforeEach(() => {
fixture = TestBed.createComponent(SearchResultsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create and load results', () => {
expect(component).toBeTruthy();
expect(component.termItems.length).toBe(3);
expect(component.geneItems.length).toBe(3);
expect(component.diseaseItems.length).toBe(2);
});

it('defaults to Most Relevant, which is the order the API returned', () => {
expect(component.sortBy.value).toBe('relevant');
expect(ids()).toEqual(['HP:0000010', 'HP:0000002', 'HP:0000100']);
});

it('sorts by name A-Z, case-insensitively', () => {
selectSort('name-asc');
expect(titles()).toEqual(['Alpha phenotype', 'Bravo phenotype', 'zulu phenotype']);
});

it('sorts by name Z-A', () => {
selectSort('name-desc');
expect(titles()).toEqual(['zulu phenotype', 'Bravo phenotype', 'Alpha phenotype']);
});

it('sorts by identifier A-Z', () => {
selectSort('identifier-asc');
expect(ids()).toEqual(['HP:0000002', 'HP:0000010', 'HP:0000100']);
});

it('sorts by identifier Z-A', () => {
selectSort('identifier-desc');
expect(ids()).toEqual(['HP:0000100', 'HP:0000010', 'HP:0000002']);
});

it('compares identifiers numerically, not lexicographically', () => {
component.onTabChange('gene');
selectSort('identifier-asc');
// Lexicographic order would be NCBIGene:10, NCBIGene:100, NCBIGene:2.
expect(ids()).toEqual(['NCBIGene:2', 'NCBIGene:10', 'NCBIGene:100']);
});

it('restores the original API order when switching back to Most Relevant', () => {
const relevanceOrder = ids();

selectSort('name-asc');
expect(ids()).not.toEqual(relevanceOrder);

selectSort('relevant');
expect(ids()).toEqual(relevanceOrder);
});

it('does not mutate the underlying category array when sorting', () => {
selectSort('name-asc');
expect(component.termItems.map((i) => i.subtitle)).toEqual([
'HP:0000010',
'HP:0000002',
'HP:0000100',
]);
});

it('re-applies the current sort after switching tabs', () => {
selectSort('name-asc');
component.onTabChange('disease');

expect(titles()).toEqual(['Marfan syndrome', 'Prune belly syndrome']);
});

it('resets to the first page when the sort changes', () => {
component.first = 10;
selectSort('name-asc');

expect(component.first).toBe(0);
});

it('pages from the sorted list, not the API order', () => {
component.rows = 1;
selectSort('name-asc');

expect(component.pagedItems.map((i) => i.title)).toEqual(['Alpha phenotype']);
});
});
36 changes: 34 additions & 2 deletions src/app/browser/pages/search-results/search-results.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ export class SearchResultsComponent {
subcategoryPills = ['Placeholder Subcategory', 'Placeholder Subcategory', 'Placeholder Subcategory'];
subcategoryPillActive: boolean[] = this.subcategoryPills.map(() => false);

// Visual-only stub: options match the Figma menu, but no sort logic is wired yet.
// "Most Relevant" is whatever order the search API returns - the other options
// are applied client-side until the search endpoint supports sorting.
sortOptions: SortOption[] = [
{ label: 'Most Relevant', value: 'relevant' },
{ label: 'Identifier (A-Z)', value: 'identifier-asc' },
Expand Down Expand Up @@ -71,6 +72,10 @@ export class SearchResultsComponent {
});
}

// Sorted copy of the active category, recomputed only when the data, the active
// tab or the sort option changes - a getter would re-sort on every change detection.
sortedItems: SearchResultItem[] = [];

get activeCategoryItems(): SearchResultItem[] {
switch (this.activeCategory) {
case 'disease':
Expand All @@ -83,12 +88,38 @@ export class SearchResultsComponent {
}

get pagedItems(): SearchResultItem[] {
return this.activeCategoryItems.slice(this.first, this.first + this.rows);
return this.sortedItems.slice(this.first, this.first + this.rows);
}

onTabChange(category: string | number | undefined): void {
this.activeCategory = this.normalizeCategory(category as string);
this.first = 0;
this.applySort();
}

onSortChange(): void {
this.first = 0;
this.applySort();
}

private applySort(): void {
const items = this.activeCategoryItems;

// "Most Relevant" is the order the API returned the results in.
if (this.sortBy?.value === 'relevant') {
this.sortedItems = items;
return;
}

// Identifiers are prefixed and numeric (HP:0001250, OMIM:154700), so compare
// numerically to keep 0000002 ahead of 0000010.
const [field, direction] = this.sortBy.value.split('-');
const key: keyof SearchResultItem = field === 'identifier' ? 'subtitle' : 'title';
const sign = direction === 'desc' ? -1 : 1;

this.sortedItems = [...items].sort(
(a, b) => sign * String(a[key] ?? '').localeCompare(String(b[key] ?? ''), undefined, { numeric: true, sensitivity: 'base' }),
);
}

// "All Terms" tab is hidden for now (see activeCategory) - treat it as Phenotypes
Expand Down Expand Up @@ -138,6 +169,7 @@ export class SearchResultsComponent {

this.allItems = [...this.termItems, ...this.diseaseItems, ...this.geneItems];
this.first = 0;
this.applySort();
this.isLoading = false;
}, (error) => {
console.log(error);
Expand Down
11 changes: 11 additions & 0 deletions src/test-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,14 @@ setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});

// jsdom has no ResizeObserver, but several PrimeNG components (p-tablist,
// p-select) bind one in ngAfterViewInit. Layout isn't observable in jsdom
// anyway, so a no-op stub is enough to let those components render.
if (!globalThis.ResizeObserver) {
globalThis.ResizeObserver = class {
observe(): void { /* no-op */ }
unobserve(): void { /* no-op */ }
disconnect(): void { /* no-op */ }
};
}
Loading