This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new 3b19f76cc3 test(frontend): render the hub search result with its real
children (#7661)
3b19f76cc3 is described below
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);
+ });
+});