This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/main/pr-6581-df52201d9749f65ad0e5dd9626bdf5a4518fbedc
in repository https://gitbox.apache.org/repos/asf/texera.git

commit c9d3f2df24f6df0e1ce8fb9f1efc2c4092be7d93
Author: Xinyuan Lin <[email protected]>
AuthorDate: Sun Jul 19 22:50:27 2026 -0700

    test(frontend): add unit tests for HubSearchResultComponent (#6581)
    
    ### What changes were proposed in this PR?
    
    Adds a Vitest unit spec for `HubSearchResultComponent`, which previously
    had no test coverage. The 17 tests cover:
    - `ngOnInit` URL branching (`dataset` -> `CreateTimeDesc`; `workflow` ->
    default).
    - `viewMode` initial value from `localStorage` and `setViewMode` (no-op
    when unchanged; persists on change).
    - `search()`: early returns when the filters/results ViewChildren are
    unset; `reset` + `loadMore`; same-filter-list + same-sort dedup vs
    `forced`; re-run on sort-method change; and `pid` -> `projectIds`
    injection into the search params (asserted by invoking the captured
    loader against the mocked `SearchService`).
    - template rendering for the dataset vs workflow view-toggle.
    
    Heavy child components are stripped via the additive `remove`/`add`
    override with same-selector stubs.
    
    ### Any related issues, documentation, discussions?
    
    Closes #6578.
    
    ### How was this PR tested?
    
    `npx ng test --watch=false
    --include='**/hub-search-result.component.spec.ts'` -> 17/17 passing.
    `yarn format:ci` passes.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 4.8 [1M context])
---
 .../hub-search-result.component.spec.ts            | 379 +++++++++++++++++++++
 1 file changed, 379 insertions(+)

diff --git 
a/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.spec.ts
 
b/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.spec.ts
new file mode 100644
index 0000000000..59aa576d10
--- /dev/null
+++ 
b/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.spec.ts
@@ -0,0 +1,379 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, EventEmitter, forwardRef, Input, Output, TemplateRef } 
from "@angular/core";
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { Router } from "@angular/router";
+import { NzIconModule } from "ng-zorro-antd/icon";
+import { AppstoreOutline, BarsOutline } from "@ant-design/icons-angular/icons";
+import { of, Subject } from "rxjs";
+import { vi } from "vitest";
+
+import { HubSearchResultComponent } from "./hub-search-result.component";
+import { SearchResultsComponent } from 
"../../../dashboard/component/user/search-results/search-results.component";
+import { FiltersComponent } from 
"../../../dashboard/component/user/filters/filters.component";
+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 { UserService } from "../../../common/service/user/user.service";
+import { StubUserService } from 
"../../../common/service/user/stub-user.service";
+import { SearchService } from "../../../dashboard/service/user/search.service";
+import { commonTestProviders } from "../../../common/testing/test-utils";
+
+const VIEW_MODE_STORAGE_KEY = "texera.hub.dataset.viewMode";
+
+/**
+ * Minimal same-selector stubs that replace the heavy real children in the
+ * component's own `imports`. The filters stub additionally provides the
+ * `FiltersComponent` token so the `@ViewChild(FiltersComponent)` query still
+ * resolves to it (the real setter dereferences `masterFilterListChange`, so
+ * resolving to `undefined` would throw during change detection).
+ */
+@Component({
+  selector: "texera-sort-button",
+  standalone: true,
+  template: "",
+})
+class StubSortButtonComponent {
+  @Input() showEditTime?: boolean;
+  @Input() showExecutionTime?: boolean;
+  @Output() sortMethodChange = new EventEmitter<SortMethod>();
+}
+
+@Component({
+  selector: "texera-filters",
+  standalone: true,
+  template: "",
+  providers: [{ provide: FiltersComponent, useExisting: forwardRef(() => 
StubFiltersComponent) }],
+})
+class StubFiltersComponent {
+  masterFilterList: ReadonlyArray<string> = [];
+  masterFilterListChange = new Subject<ReadonlyArray<string>>();
+  getSearchKeywords = vi.fn(() => [] as string[]);
+  getSearchFilterParameters = vi.fn(() => ({}));
+}
+
+@Component({
+  selector: "texera-search-results",
+  standalone: true,
+  template: "",
+})
+class StubSearchResultsComponent {
+  @Input() showResourceTypes?: boolean;
+  @Input() searchKeywords?: string[];
+  @Input() currentUid?: number;
+  @Input() viewMode?: string;
+  @Input() cardTemplate?: TemplateRef<unknown>;
+}
+
+@Component({
+  selector: "texera-card-item",
+  standalone: true,
+  template: "",
+})
+class StubCardItemComponent {
+  @Input() entry?: unknown;
+  @Input() currentUid?: number;
+}
+
+interface FiltersMock {
+  masterFilterList: ReadonlyArray<string>;
+  getSearchKeywords: ReturnType<typeof vi.fn>;
+  getSearchFilterParameters: ReturnType<typeof vi.fn>;
+  masterFilterListChange: Subject<ReadonlyArray<string>>;
+}
+
+interface SearchResultsMock {
+  reset: ReturnType<typeof vi.fn>;
+  loadMore: ReturnType<typeof vi.fn>;
+}
+
+describe("HubSearchResultComponent", () => {
+  let fixture: ComponentFixture<HubSearchResultComponent>;
+  let component: HubSearchResultComponent;
+
+  let routerMock: { url: string };
+  let searchServiceMock: { executeSearch: ReturnType<typeof vi.fn> };
+  let setItemSpy: ReturnType<typeof vi.spyOn>;
+
+  function configure(url: string): void {
+    routerMock = { url };
+    searchServiceMock = {
+      executeSearch: vi.fn().mockReturnValue(of({ entries: [], more: false })),
+    };
+
+    TestBed.overrideComponent(HubSearchResultComponent, {
+      remove: {
+        imports: [SortButtonComponent, FiltersComponent, 
SearchResultsComponent, CardItemComponent],
+      },
+      add: {
+        imports: [StubSortButtonComponent, StubFiltersComponent, 
StubSearchResultsComponent, StubCardItemComponent],
+      },
+    });
+
+    TestBed.configureTestingModule({
+      imports: [HubSearchResultComponent, NzIconModule.forChild([BarsOutline, 
AppstoreOutline])],
+      providers: [
+        { provide: Router, useValue: routerMock },
+        { provide: SearchService, useValue: searchServiceMock },
+        { provide: UserService, useClass: StubUserService },
+        ...commonTestProviders,
+      ],
+    });
+  }
+
+  function build(url: string, detectChanges: boolean = true): void {
+    configure(url);
+    fixture = TestBed.createComponent(HubSearchResultComponent);
+    component = fixture.componentInstance;
+    if (detectChanges) {
+      fixture.detectChanges();
+    }
+  }
+
+  function makeFiltersMock(list: ReadonlyArray<string> = ["a"]): FiltersMock {
+    return {
+      masterFilterList: list,
+      getSearchKeywords: vi.fn(() => ["k"]),
+      getSearchFilterParameters: vi.fn(() => ({})),
+      masterFilterListChange: new Subject<ReadonlyArray<string>>(),
+    };
+  }
+
+  function makeSearchResultsMock(): SearchResultsMock {
+    return {
+      reset: vi.fn(),
+      loadMore: vi.fn().mockResolvedValue(undefined),
+    };
+  }
+
+  /** Directly assign the ViewChild backing fields with test doubles. */
+  function attachChildren(filters: FiltersMock | undefined, results: 
SearchResultsMock | undefined): void {
+    component["_filters"] = filters as unknown as FiltersComponent;
+    component["_searchResultsComponent"] = results as unknown as 
SearchResultsComponent;
+  }
+
+  beforeEach(() => {
+    localStorage.clear();
+    setItemSpy = vi.spyOn(Storage.prototype, "setItem");
+  });
+
+  afterEach(() => {
+    fixture?.destroy();
+    vi.restoreAllMocks();
+    localStorage.clear();
+    document.querySelectorAll(".cdk-overlay-container").forEach(el => 
el.remove());
+  });
+
+  describe("ngOnInit / searchType resolution", () => {
+    it("resolves 'dataset' and defaults sortMethod to CreateTimeDesc when the 
url contains 'dataset'", () => {
+      build("/dashboard/dataset");
+      expect(component.searchType).toBe("dataset");
+      expect(component.sortMethod).toBe(SortMethod.CreateTimeDesc);
+    });
+
+    it("resolves 'workflow' and keeps the default EditTimeDesc when the url 
contains 'workflow'", () => {
+      build("/dashboard/workflow");
+      expect(component.searchType).toBe("workflow");
+      expect(component.sortMethod).toBe(SortMethod.EditTimeDesc);
+    });
+
+    it("keeps the default 'workflow' searchType when the url matches neither 
branch", () => {
+      build("/dashboard/project");
+      expect(component.searchType).toBe("workflow");
+      expect(component.sortMethod).toBe(SortMethod.EditTimeDesc);
+    });
+  });
+
+  describe("viewMode initialization", () => {
+    it("reads 'card' from localStorage", () => {
+      localStorage.setItem(VIEW_MODE_STORAGE_KEY, "card");
+      build("/dashboard/dataset");
+      expect(component.viewMode).toBe("card");
+    });
+
+    it("falls back to 'list' when localStorage holds a non-'card' value", () 
=> {
+      localStorage.setItem(VIEW_MODE_STORAGE_KEY, "grid");
+      build("/dashboard/dataset");
+      expect(component.viewMode).toBe("list");
+    });
+
+    it("falls back to 'list' when localStorage is empty", () => {
+      build("/dashboard/dataset");
+      expect(component.viewMode).toBe("list");
+    });
+  });
+
+  describe("setViewMode", () => {
+    it("is a no-op (no localStorage write) when the mode is unchanged", () => {
+      build("/dashboard/dataset");
+      expect(component.viewMode).toBe("list");
+      setItemSpy.mockClear();
+
+      component.setViewMode("list");
+
+      expect(component.viewMode).toBe("list");
+      expect(setItemSpy).not.toHaveBeenCalled();
+    });
+
+    it("updates viewMode and persists to localStorage when the mode changes", 
() => {
+      build("/dashboard/dataset");
+      setItemSpy.mockClear();
+
+      component.setViewMode("card");
+
+      expect(component.viewMode).toBe("card");
+      expect(setItemSpy).toHaveBeenCalledWith(VIEW_MODE_STORAGE_KEY, "card");
+      expect(localStorage.getItem(VIEW_MODE_STORAGE_KEY)).toBe("card");
+    });
+  });
+
+  describe("template rendering", () => {
+    it("renders the dataset view-toggle when searchType is 'dataset'", () => {
+      build("/dashboard/dataset");
+      const host = fixture.nativeElement as HTMLElement;
+      expect(host.querySelector(".view-toggle")).not.toBeNull();
+      expect(host.querySelector("texera-search-results")).not.toBeNull();
+      expect(host.querySelector("texera-filters")).not.toBeNull();
+    });
+
+    it("hides the dataset view-toggle when searchType is 'workflow'", () => {
+      build("/dashboard/workflow");
+      const host = fixture.nativeElement as HTMLElement;
+      expect(host.querySelector(".view-toggle")).toBeNull();
+    });
+  });
+
+  describe("search", () => {
+    it("early-returns when filters is unset", async () => {
+      build("/dashboard/workflow");
+      const results = makeSearchResultsMock();
+      attachChildren(undefined, results);
+
+      await component.search();
+
+      expect(results.reset).not.toHaveBeenCalled();
+      expect(searchServiceMock.executeSearch).not.toHaveBeenCalled();
+    });
+
+    it("early-returns when searchResultsComponent is unset", async () => {
+      build("/dashboard/workflow");
+      const filters = makeFiltersMock();
+      attachChildren(filters, undefined);
+
+      await component.search();
+
+      expect(filters.getSearchKeywords).not.toHaveBeenCalled();
+      expect(searchServiceMock.executeSearch).not.toHaveBeenCalled();
+    });
+
+    it("resets with a loader and calls loadMore on the first search", async () 
=> {
+      build("/dashboard/workflow");
+      const filters = makeFiltersMock();
+      const results = makeSearchResultsMock();
+      attachChildren(filters, results);
+
+      await component.search();
+
+      expect(results.reset).toHaveBeenCalledTimes(1);
+      expect(typeof results.reset.mock.calls[0][0]).toBe("function");
+      expect(results.loadMore).toHaveBeenCalledTimes(1);
+      expect(component.searchKeywords).toEqual(["k"]);
+    });
+
+    it("skips a repeated search with the same filter list and sortMethod, but 
honors forced=true", async () => {
+      build("/dashboard/workflow");
+      const filters = makeFiltersMock();
+      const results = makeSearchResultsMock();
+      attachChildren(filters, results);
+
+      await component.search();
+      expect(results.reset).toHaveBeenCalledTimes(1);
+
+      // identical masterFilterList + sortMethod -> deduped, no new reset.
+      await component.search();
+      expect(results.reset).toHaveBeenCalledTimes(1);
+
+      // forced overrides the dedupe guard.
+      await component.search(true);
+      expect(results.reset).toHaveBeenCalledTimes(2);
+    });
+
+    it("re-searches when the sortMethod changes even if the filter list is 
identical", async () => {
+      build("/dashboard/workflow");
+      const filters = makeFiltersMock();
+      const results = makeSearchResultsMock();
+      attachChildren(filters, results);
+
+      await component.search();
+      expect(results.reset).toHaveBeenCalledTimes(1);
+
+      component.sortMethod = SortMethod.NameAsc;
+      await component.search();
+      expect(results.reset).toHaveBeenCalledTimes(2);
+    });
+
+    it("passes projectIds=[pid] into the executeSearch loader when pid is 
set", async () => {
+      build("/dashboard/workflow");
+      component.pid = 42;
+      const filters = makeFiltersMock();
+      const results = makeSearchResultsMock();
+      attachChildren(filters, results);
+
+      await component.search();
+
+      const loader = results.reset.mock.calls[0][0] as (start: number, count: 
number) => Promise<unknown>;
+      await loader(0, 20);
+
+      expect(searchServiceMock.executeSearch).toHaveBeenCalledWith(
+        [""],
+        expect.objectContaining({ projectIds: [42] }),
+        0,
+        20,
+        "workflow",
+        SortMethod.EditTimeDesc,
+        false,
+        true
+      );
+    });
+
+    it("does not inject projectIds when pid is undefined", async () => {
+      build("/dashboard/workflow");
+      const filters = makeFiltersMock();
+      const results = makeSearchResultsMock();
+      attachChildren(filters, results);
+
+      await component.search();
+
+      const loader = results.reset.mock.calls[0][0] as (start: number, count: 
number) => Promise<unknown>;
+      await loader(0, 20);
+
+      expect(searchServiceMock.executeSearch).toHaveBeenCalledWith(
+        [""],
+        {},
+        0,
+        20,
+        "workflow",
+        SortMethod.EditTimeDesc,
+        false,
+        true
+      );
+    });
+  });
+});

Reply via email to