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-7661-cb811a884ef505bce60d8ca372505ca6642418eb in repository https://gitbox.apache.org/repos/asf/texera.git
commit 3b19f76cc357788c5dd47f2f87aa50ce9e476fe6 Author: Xinyuan Lin <[email protected]> AuthorDate: Sat Aug 15 01:39:05 2026 +0000 test(frontend): render the hub search result with its real children (#7661) ### What changes were proposed in this PR? `hub-search-result.component.html` reported **0 of 23 lines, 0 of 8 branches and 0 of 3 functions** covered, behind a 17-test spec. That is the attribution loss from #7458, not an untested template: the spec swaps its four children for same-selector stubs via `TestBed.overrideComponent`, and any override re-JITs the component from its decorator metadata, leaving the recompiled template with no source map back to the `.html`. Adds a `describe` block that renders the component with its **real** children: | | Before | After | |---|---|---| | lines | 0/23 | **23/23** | | branches | 0/8 | **8/8** | | functions | 0/3 | **3/3** | The block keeps its own `TestBed`, so the 17 existing tests keep their stubs and assertions untouched. Same remedy as merged PRs #7535, #7627 and #7629 — this is the last of the six templates #7458 identified, bar `workspace` and the `user-project` one that the project-feature removal deletes. Covered: the real children resolving rather than the stub selectors, the dataset-only view toggle and its absence for workflows, which toggle button is highlighted, the sort options shown and hidden per search type, the sort handler's two halves, the card template and its `viewMode` guard, and the three inputs handed to the results list. ### Verification 23 mutations applied and reverted, production diff empty each time. **Two then turned out to survive, and the cause is worth recording.** The view-toggle helper read each button as `classList.contains("ant-btn-primary")`, which pins a ternary's *true* leg only — so changing the false leg from `'default'` to `'dashed'` or `'link'` shipped green. The build's own mutations replaced the whole ternary with a constant, which flips the primary leg and dies, which is exactly why the gap was missed. The helper now reads the type by **name**, matched against the nzType names rather than any `ant-btn-*` class, since the buttons also carry `ant-btn-icon-only` — my first attempt at the fix picked that modifier up and failed loudly, which is how I found it. All three are now red, each on the intended test rather than merely somewhere in the suite: | Mutation | Result | Failing test | |---|---|---| | list button's false leg → `dashed` | red | "highlights whichever view-toggle button matches…" | | card button's false leg → `link` | red | same | | list button collapsed to a constant (control) | red | same | ### Deliberately not included A static `nzTheme="outline"` attribute carries no lcov line and is not asserted. No production file is touched. ### Any related issues, documentation, discussions? Closes #7660 ### How was this PR tested? ``` npx ng test --watch=false --include="**/hub-search-result.component.spec.ts" ``` ``` Test Files 1 passed (1) Tests 27 passed (27) ``` 10 new on top of the existing 17. Coverage re-measured by reverting the spec, running with `--coverage`, and restoring. `yarn format:ci` passes. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 5) --- .../hub-search-result.component.spec.ts | 255 ++++++++++++++++++++- 1 file changed, 252 insertions(+), 3 deletions(-) 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 index 59aa576d10..cfc5a4936a 100644 --- 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 @@ -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"; @@ -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"; @@ -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); + }); +});
