From 6e09bb621013e89573ae4a99c3707b577e98551f Mon Sep 17 00:00:00 2001 From: Xinyuan Lin Date: Wed, 12 Aug 2026 22:52:10 -0700 Subject: [PATCH] test(frontend): render the dashboard search with its real children search.component.html reported 0 of 35 lines covered while its own .ts sat at 93%. That is not an untested template - it is the attribution loss recorded in #7458: the spec stubs its children out through TestBed.overrideComponent, and any override makes Angular re-JIT the component from its decorator metadata, leaving the re-compiled template with no source map back to the .html. Adds a describe block that renders the component with its real children, which restores attribution: the template goes from 0/35 to 35/35 lines and 6/6 functions, and the .ts rises too. The block keeps its own TestBed so the 15 existing tests keep their stubs untouched. No production file is touched. --- .../user/search/search.component.spec.ts | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/frontend/src/app/dashboard/component/user/search/search.component.spec.ts b/frontend/src/app/dashboard/component/user/search/search.component.spec.ts index f8e0673d679..2b100a02c95 100644 --- a/frontend/src/app/dashboard/component/user/search/search.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/search/search.component.spec.ts @@ -30,6 +30,21 @@ import { SearchService } from "../../../service/user/search.service"; import { UserService } from "../../../../common/service/user/user.service"; import { SortMethod } from "../../../type/sort-method"; import { commonTestProviders } from "../../../../common/testing/test-utils"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { FormsModule } from "@angular/forms"; +import { JWT_OPTIONS, JwtHelperService } from "@auth0/angular-jwt"; +import { NzDropDownModule } from "ng-zorro-antd/dropdown"; +import { NzModalModule } from "ng-zorro-antd/modal"; +import { en_US, provideNzI18n } from "ng-zorro-antd/i18n"; +import { By } from "@angular/platform-browser"; +import { MOCK_USER_ID, StubUserService } from "../../../../common/service/user/stub-user.service"; +import { OperatorMetadataService } from "src/app/workspace/service/operator-metadata/operator-metadata.service"; +import { StubOperatorMetadataService } from "src/app/workspace/service/operator-metadata/stub-operator-metadata.service"; +import { UserProjectService } from "../../../service/user/project/user-project.service"; +import { StubUserProjectService } from "../../../service/user/project/stub-user-project.service"; +import { WorkflowPersistService } from "src/app/common/service/workflow-persist/workflow-persist.service"; +import { StubWorkflowPersistService } from "src/app/common/service/workflow-persist/stub-workflow-persist.service"; +import { SortButtonComponent } from "../sort-button/sort-button.component"; // Lightweight stand-in for FiltersComponent. It registers itself under the real // FiltersComponent token so SearchComponent's `@ViewChild(FiltersComponent)` @@ -240,3 +255,119 @@ describe("SearchComponent", () => { expect(component.sortMethod).toBe(SortMethod.EditTimeDesc); }); }); + +// The suite above stubs the children out, and *any* `overrideComponent` makes +// Angular re-JIT SearchComponent from its retained decorator metadata; the +// recompiled template loses its source map back to search.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("SearchComponent rendered template", () => { + let fixture: ComponentFixture; + let locationStub: { back: ReturnType }; + let executeSearch: ReturnType; + + const host = (): HTMLElement => fixture.nativeElement as HTMLElement; + + /** The four resource-type buttons — direct children of .left-controls, which excludes the sort button. */ + const typeButtons = (): HTMLButtonElement[] => + Array.from(host().querySelectorAll(".left-controls > button")); + + const labels = (): string[] => typeButtons().map(button => button.textContent!.trim()); + + const selectedFlags = (): boolean[] => typeButtons().map(button => button.classList.contains("selected")); + + /** The `type` argument of the most recent SearchService.executeSearch call. */ + const lastSearchedType = (): unknown => executeSearch.mock.calls[executeSearch.mock.calls.length - 1][4]; + + beforeEach(async () => { + TestBed.resetTestingModule(); + locationStub = { back: vi.fn() }; + executeSearch = vi.fn(() => of({ entries: [], more: false })); + + await TestBed.configureTestingModule({ + imports: [SearchComponent, NzModalModule, NzDropDownModule, FormsModule, HttpClientTestingModule], + providers: [ + JwtHelperService, + { provide: JWT_OPTIONS, useValue: {} }, + { provide: SearchService, useValue: { executeSearch } }, + { provide: UserService, useClass: StubUserService }, + { provide: OperatorMetadataService, useClass: StubOperatorMetadataService }, + { provide: UserProjectService, useClass: StubUserProjectService }, + { provide: WorkflowPersistService, useValue: new StubWorkflowPersistService([]) }, + { provide: ActivatedRoute, useValue: { queryParams: new Subject() } }, + { provide: Location, useValue: locationStub }, + provideNzI18n(en_US), + ...commonTestProviders, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SearchComponent); + fixture.detectChanges(); + }); + + it("renders the four resource-type buttons, each with its own label and icon", () => { + expect(labels()).toEqual(["All", "Project", "Workflow", "Dataset"]); + // nz-icon turns nzType into an `anticon-` class, so this pins the icon + // each button asks for — and that "All" asks for none. + const icons = typeButtons().map(button => { + const icon = button.querySelector("span[nz-icon]"); + return icon && Array.from(icon.classList).find(name => name.startsWith("anticon-")); + }); + expect(icons).toEqual([null, "anticon-container", "anticon-project", "anticon-database"]); + }); + + it("highlights only the All button before a resource type is chosen", () => { + expect(selectedFlags()).toEqual([true, false, false, false]); + }); + + it("moves the highlight onto whichever resource-type button is clicked", () => { + // Click each typed button in turn; the highlight must be one-hot on that + // same button, which pins both its (click) argument and its [ngClass] test. + typeButtons().forEach((button, index) => { + button.click(); + fixture.detectChanges(); + expect(selectedFlags()).toEqual(labels().map((_, i) => i === index)); + }); + }); + + it("scopes the search to the clicked resource type", () => { + typeButtons()[3].click(); + expect(lastSearchedType()).toBe("dataset"); + + // Back to All: the type filter is cleared rather than left on "dataset". + typeButtons()[0].click(); + expect(lastSearchedType()).toBeNull(); + }); + + it("re-runs the search with the sort method the sort button emits", () => { + 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 at all. + expect(executeSearch).toHaveBeenCalledTimes(1); + expect(executeSearch.mock.calls[0][5]).toBe(SortMethod.CreateTimeDesc); + }); + + it("renders a back arrow that returns to the previous page", () => { + const goBack = host().querySelector(".go-back-button")!; + expect(goBack.querySelector("span.anticon-arrow-left")).not.toBeNull(); + + goBack.click(); + + expect(locationStub.back).toHaveBeenCalledTimes(1); + }); + + it("hands the signed-in user's uid to the results list", () => { + const results = fixture.debugElement.query(By.directive(SearchResultsComponent)) + .componentInstance as SearchResultsComponent; + // Asserted on the child input rather than the DOM because currentUid only + // reaches the markup through texera-list-item, which is not rendered while + // the result list is empty. + expect(results.currentUid).toBe(MOCK_USER_ID); + }); +});