Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,14 @@
*/

import { Component, EventEmitter, forwardRef, Input, Output, TemplateRef } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { Router } from "@angular/router";
import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
import { provideRouter, Router } from "@angular/router";
import { HttpClientTestingModule } from "@angular/common/http/testing";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { By } from "@angular/platform-browser";
import { NzIconModule } from "ng-zorro-antd/icon";
import { NzModalService } from "ng-zorro-antd/modal";
import { en_US, provideNzI18n } from "ng-zorro-antd/i18n";
import { AppstoreOutline, BarsOutline } from "@ant-design/icons-angular/icons";
import { of, Subject } from "rxjs";
import { vi } from "vitest";
Expand All @@ -31,10 +36,19 @@ import { FiltersComponent } from "../../../dashboard/component/user/filters/filt
import { CardItemComponent } from "../../../dashboard/component/user/list-item/card-item/card-item.component";
import { SortButtonComponent } from "../../../dashboard/component/user/sort-button/sort-button.component";
import { SortMethod } from "../../../dashboard/type/sort-method";
import { DashboardEntry } from "../../../dashboard/type/dashboard-entry";
import { UserService } from "../../../common/service/user/user.service";
import { StubUserService } from "../../../common/service/user/stub-user.service";
import { MOCK_USER_ID, StubUserService } from "../../../common/service/user/stub-user.service";
import { SearchService } from "../../../dashboard/service/user/search.service";
import { commonTestProviders } from "../../../common/testing/test-utils";
import { OperatorMetadataService } from "../../../workspace/service/operator-metadata/operator-metadata.service";
import { StubOperatorMetadataService } from "../../../workspace/service/operator-metadata/stub-operator-metadata.service";
import { UserProjectService } from "../../../dashboard/service/user/project/user-project.service";
import { StubUserProjectService } from "../../../dashboard/service/user/project/stub-user-project.service";
import { WorkflowPersistService } from "../../../common/service/workflow-persist/workflow-persist.service";
import { StubWorkflowPersistService } from "../../../common/service/workflow-persist/stub-workflow-persist.service";
import { DatasetService } from "../../../dashboard/service/user/dataset/dataset.service";
import { WorkflowCoverService } from "../../../dashboard/service/user/workflow-cover/workflow-cover.service";

const VIEW_MODE_STORAGE_KEY = "texera.hub.dataset.viewMode";

Expand Down Expand Up @@ -377,3 +391,238 @@ describe("HubSearchResultComponent", () => {
});
});
});

// The suite above stubs the children out, and *any* `overrideComponent` makes
// Angular re-JIT HubSearchResultComponent from its retained decorator metadata;
// the recompiled template loses its source map back to
// hub-search-result.component.html, so every binding still runs but none of it
// is attributed (issue #7458). This suite therefore stands up its own TestBed
// with the REAL children and asserts on the rendered DOM, leaving the stubbed
// tests above untouched.
describe("HubSearchResultComponent rendered template", () => {
let fixture: ComponentFixture<HubSearchResultComponent>;
let executeSearch: ReturnType<typeof vi.fn>;
let entries: DashboardEntry[];

const host = (): HTMLElement => fixture.nativeElement as HTMLElement;

const toggleButtons = (): HTMLButtonElement[] =>
Array.from(host().querySelectorAll<HTMLButtonElement>(".view-toggle button"));

/**
* nz-button renders nzType as an `ant-btn-<type>` class, so this reads the [nzType] ternaries
* back off the DOM. It reports the type NAME rather than a primary/not-primary boolean on
* purpose: a boolean read pins each ternary's false leg only as "not primary", so changing
* `'default'` to `'dashed'` or `'link'` would ship green.
*/
const toggleTypes = (): string[] => {
// Matched against the nzType names rather than any `ant-btn-*` class, because the buttons also
// carry modifier classes such as `ant-btn-icon-only`.
const names = ["primary", "default", "dashed", "link", "text"];
return toggleButtons().map(button => names.find(name => button.classList.contains(`ant-btn-${name}`)) ?? "none");
};

const cardItems = (): CardItemComponent[] =>
fixture.debugElement.queryAll(By.directive(CardItemComponent)).map(item => item.componentInstance);

const cardNames = (): string[] =>
Array.from(host().querySelectorAll(".card-grid texera-card-item .resource-name")).map(name =>
name.textContent!.trim()
);

const results = (): SearchResultsComponent =>
fixture.debugElement.query(By.directive(SearchResultsComponent)).componentInstance;

/** The sort options the real sort button offers, read out of the cdk overlay it opens on hover. */
const sortMenuLabels = (): string[] =>
Array.from(document.querySelectorAll(".cdk-overlay-container li[nz-menu-item]")).map(item =>
item.textContent!.trim()
);

function openSortMenu(): void {
host().querySelector("texera-sort-button a")!.dispatchEvent(new MouseEvent("mouseenter"));
tick(500);
fixture.detectChanges();
}

function makeDatasetEntry(id: number, name: string): DashboardEntry {
return {
id,
name,
description: "",
type: "dataset",
dataset: { isOwner: true },
accessibleUserIds: [],
likeCount: 0,
viewCount: 0,
isLiked: false,
size: 0,
} as unknown as DashboardEntry;
}

function render(url: string, storedViewMode?: string): void {
TestBed.resetTestingModule();
localStorage.clear();
if (storedViewMode !== undefined) {
localStorage.setItem(VIEW_MODE_STORAGE_KEY, storedViewMode);
}
executeSearch = vi.fn(() => of({ entries, more: false }));

TestBed.configureTestingModule({
imports: [
HubSearchResultComponent,
NzIconModule.forChild([BarsOutline, AppstoreOutline]),
HttpClientTestingModule,
NoopAnimationsModule,
],
providers: [
provideRouter([]),
{ provide: SearchService, useValue: { executeSearch } },
{ provide: UserService, useClass: StubUserService },
{ provide: OperatorMetadataService, useClass: StubOperatorMetadataService },
{ provide: UserProjectService, useClass: StubUserProjectService },
{ provide: WorkflowPersistService, useValue: new StubWorkflowPersistService([]) },
{ provide: DatasetService, useValue: { getDatasetCoverUrl: vi.fn(() => of({ url: undefined })) } },
{ provide: WorkflowCoverService, useValue: { getCover: vi.fn(() => of(undefined)) } },
NzModalService,
provideNzI18n(en_US),
...commonTestProviders,
],
});

// ngOnInit derives searchType from Router.url; an own property shadows the real getter.
Object.defineProperty(TestBed.inject(Router), "url", { get: () => url });

fixture = TestBed.createComponent(HubSearchResultComponent);
fixture.detectChanges();
}

/** Entries only reach the DOM through a search, which is how the card template gets instantiated. */
async function loadEntries(list: DashboardEntry[]): Promise<void> {
entries = list;
await fixture.componentInstance.search(true);
fixture.detectChanges();
}

beforeEach(() => {
entries = [];
});

afterEach(() => {
fixture?.destroy();
localStorage.clear();
document.querySelectorAll(".cdk-overlay-container").forEach(el => el.remove());
});

it("renders the real children, not the stubbed selectors", () => {
// If these resolve to empty stub templates the component was re-JITed and the
// template's coverage has silently gone back to zero.
render("/dashboard/dataset");

expect(host().querySelector("texera-sort-button button#sortDropdown")).not.toBeNull();
expect(host().querySelector("texera-filters button")).not.toBeNull();
expect(host().querySelector("texera-search-results nz-card")).not.toBeNull();
});

it("renders both dataset view-toggle buttons, each with its own label and icon", () => {
render("/dashboard/dataset");

expect(toggleButtons().map(button => button.title)).toEqual(["List view", "Card view"]);
// nz-icon turns nzType into an `anticon-<type>` class, so this pins which icon each button asks for.
expect(toggleButtons().map(button => button.querySelector("i[nz-icon]")!.className)).toEqual([
expect.stringContaining("anticon-bars"),
expect.stringContaining("anticon-appstore"),
]);
});

it("omits the view toggle entirely when the search type is workflow", () => {
render("/dashboard/workflow");

expect(host().querySelector(".view-toggle")).toBeNull();
expect(toggleButtons()).toEqual([]);
// The rest of the filter bar is unaffected.
expect(host().querySelector("texera-sort-button button#sortDropdown")).not.toBeNull();
});

it("highlights whichever view-toggle button matches the current view mode", () => {
render("/dashboard/dataset");
expect(toggleTypes()).toEqual(["primary", "default"]);

toggleButtons()[1].click();
fixture.detectChanges();
expect(toggleTypes()).toEqual(["default", "primary"]);

toggleButtons()[0].click();
fixture.detectChanges();
expect(toggleTypes()).toEqual(["primary", "default"]);
});

it("hides the edit-time and execution-time sort options for datasets", fakeAsync(() => {
render("/dashboard/dataset");

openSortMenu();

expect(sortMenuLabels()).toEqual(["By Create Time", "A -> Z", "Z -> A"]);
}));

it("offers the edit-time and execution-time sort options for workflows", fakeAsync(() => {
render("/dashboard/workflow");

openSortMenu();

expect(sortMenuLabels()).toEqual(["By Edit Time", "By Create Time", "By Execution Time", "A -> Z", "Z -> A"]);
}));

it("re-runs the search with the sort method the sort button emits", () => {
render("/dashboard/workflow");
const sortButton = fixture.debugElement.query(By.directive(SortButtonComponent))
.componentInstance as SortButtonComponent;

sortButton.dateSort();

// Kills both halves of `sortMethod = $event; search()`: drop the assignment and the
// search runs with the EditTimeDesc default; drop the call and executeSearch is never reached.
expect(executeSearch).toHaveBeenCalledTimes(1);
expect(executeSearch.mock.calls[0][5]).toBe(SortMethod.CreateTimeDesc);
});

it("renders every dataset entry through the card template in card mode", async () => {
render("/dashboard/dataset", "card");

await loadEntries([makeDatasetEntry(7, "alpha"), makeDatasetEntry(8, "beta")]);

expect(cardNames()).toEqual(["alpha", "beta"]);
// The like button is disabled while currentUid is undefined, so an enabled one
// is the card template's [currentUid] binding arriving in the DOM.
const likeButtons = Array.from(host().querySelectorAll<HTMLButtonElement>(".card-grid .like-btn"));
expect(likeButtons.map(button => button.disabled)).toEqual([false, false]);
expect(cardItems().map(item => item.currentUid)).toEqual([MOCK_USER_ID, MOCK_USER_ID]);
});

it("keeps the workflow search type on the list view even when card mode is stored", () => {
render("/dashboard/workflow", "card");
expect(fixture.componentInstance.viewMode).toBe("card");

expect(host().querySelector("cdk-virtual-scroll-viewport")).not.toBeNull();
expect(host().querySelector(".card-scroll-container")).toBeNull();
// The card template itself is withheld too, which the DOM cannot show while
// the results list is already pinned to the list view.
expect(results().cardTemplate).toBeUndefined();
});

it("hands the resource types, the filter keywords and the signed-in uid to the results list", () => {
render("/dashboard/workflow");
const filters = fixture.debugElement.query(By.directive(FiltersComponent)).componentInstance as FiltersComponent;

// Committing a filter list is what the real filter bar does on every change, and it
// is what makes the component republish its keywords.
filters.masterFilterList = ["alpha"];
fixture.detectChanges();

// Asserted on the child inputs rather than the DOM because all three only reach the
// markup through texera-list-item, which is not rendered while the result list is empty.
expect(results().showResourceTypes).toBe(true);
expect(results().searchKeywords).toEqual(["alpha"]);
expect(results().currentUid).toBe(MOCK_USER_ID);
});
});
Loading