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-7408-c5c2c6f8c2aeb746929a5f56b9d36eaa0373848e in repository https://gitbox.apache.org/repos/asf/texera.git
commit fe89db4315c4b23c04ae06160030566ce8410281 Author: Xinyuan Lin <[email protected]> AuthorDate: Sat Aug 8 00:16:28 2026 -0700 test(frontend): render the hub browse section's entity cards (#7408) ### What changes were proposed in this PR? The browse section's specs cover the route map and the cover-URL cache, but no card had ever been rendered, so every per-entity binding and fallback in the template was unpinned. The template was at roughly **8%** of statements locally. Adds 8 tests over what the template decides on its own: - the section disappears entirely when it holds no entities - the heading, and one card per entity, with name and description - `{{ entity.description || 'No description available' }}` — an entity published without a description would otherwise render an empty paragraph and collapse the card - `[src]="getCoverImage(entity)"` and the inline `(error)` handler that swaps in `defaultBackground`; a cached cover URL can still 404, and that handler is the only thing standing between the user and a broken image - the avatar labelled with the entity id, and the owner name defaulting to empty **Verified by mutation**, all reverted (template diff empty): | Mutation | Result | |---|---| | render the section even when empty | red | | drop the description fallback | red | | drop the owner-name fallback | red | | ignore the cover cache and always use the default | red | | remove the image error handler | red | | label the avatar with the name instead of the id | red | | show the description as the card title | red | | render only the first entity | red | Local coverage for the component directory: **~8% → 94.73%** of statements. The real `UserService` is replaced with the shared `StubUserService`: the embedded `texera-user-avatar` injects it, and the real one drags in `AuthService` and from there `JwtHelperService` and `NzModalService`. The stub cuts that chain in one step. No production file is touched. ### Any related issues, documentation, discussions? Closes #7405 ### How was this PR tested? ``` npx ng test --watch=false --include="**/browse-section.component.spec.ts" ``` ``` Test Files 1 passed (1) Tests 17 passed (17) ``` 8 new on top of the existing 9. `yarn format:ci` passes. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 5) --- .../browse-section.component.spec.ts | 106 ++++++++++++++++++++- 1 file changed, 104 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/hub/component/browse-section/browse-section.component.spec.ts b/frontend/src/app/hub/component/browse-section/browse-section.component.spec.ts index 8207eeab5f..0be21b86fe 100644 --- a/frontend/src/app/hub/component/browse-section/browse-section.component.spec.ts +++ b/frontend/src/app/hub/component/browse-section/browse-section.component.spec.ts @@ -18,10 +18,13 @@ */ import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { RouterTestingModule } from "@angular/router/testing"; +import { By } from "@angular/platform-browser"; +import { UserService } from "src/app/common/service/user/user.service"; +import { StubUserService } from "src/app/common/service/user/stub-user.service"; import { BrowseSectionComponent } from "./browse-section.component"; import { WorkflowPersistService } from "../../../common/service/workflow-persist/workflow-persist.service"; import { DatasetService } from "../../../dashboard/service/user/dataset/dataset.service"; -import { ChangeDetectorRef } from "@angular/core"; import { commonTestProviders } from "../../../common/testing/test-utils"; import { DashboardEntry } from "../../../dashboard/type/dashboard-entry"; import { AppSettings } from "../../../common/app-setting"; @@ -42,7 +45,6 @@ describe("BrowseSectionComponent", () => { providers: [ { provide: WorkflowPersistService, useValue: {} }, { provide: DatasetService, useValue: {} }, - { provide: ChangeDetectorRef, useValue: {} }, ...commonTestProviders, ], }); @@ -122,3 +124,103 @@ describe("BrowseSectionComponent", () => { }); }); }); +/** + * The cards themselves are template-only: the specs above assert the route map and the cover-URL + * cache, but nothing had ever rendered a card, so the per-entity bindings and their fallbacks were + * unpinned. RouterTestingModule supplies the Router that the cards' routerLink needs. + */ +describe("BrowseSectionComponent rendering", () => { + let fixture: ComponentFixture<BrowseSectionComponent>; + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [BrowseSectionComponent, RouterTestingModule.withRoutes([])], + providers: [ + // The cards embed texera-user-avatar, which injects UserService; the real one drags in + // AuthService and its whole dependency chain, so the shared stub stands in for it. + { provide: UserService, useClass: StubUserService }, + { provide: WorkflowPersistService, useValue: {} }, + { provide: DatasetService, useValue: {} }, + ...commonTestProviders, + ], + }); + fixture = TestBed.createComponent(BrowseSectionComponent); + }); + + /** Renders the section with the given entities. */ + function render(entities: DashboardEntry[], title = "Workflows"): HTMLElement { + // Set the inputs and let the first change-detection cycle drive ngOnInit, as Angular does at + // runtime. Calling ngOnInit() by hand as well would run it twice and rebuild the cover-image + // cache on top of itself, hiding any non-idempotent init. + fixture.componentRef.setInput("entities", entities); + fixture.componentRef.setInput("sectionTitle", title); + fixture.detectChanges(); + return fixture.nativeElement as HTMLElement; + } + + const entity = (over: Partial<Record<string, unknown>> = {}) => + ({ id: 1, type: "dataset", accessibleUserIds: [], name: "flow", ...over }) as unknown as DashboardEntry; + + it("renders nothing at all for an empty section", () => { + const el = render([]); + + expect(el.querySelector(".results-container")).toBeNull(); + }); + + it("renders the section heading and one card per entity", () => { + const el = render([entity({ id: 1 }), entity({ id: 2 })], "Public Datasets"); + + expect(el.querySelector(".results-title")?.textContent?.trim()).toBe("Public Datasets"); + expect(el.querySelectorAll("nz-card")).toHaveLength(2); + }); + + it("shows each entity's name and description", () => { + const el = render([entity({ name: "sales", description: "quarterly numbers" })]); + + expect(el.querySelector(".card-title")?.textContent?.trim()).toBe("sales"); + expect(el.querySelector(".card-description")?.textContent?.trim()).toBe("quarterly numbers"); + }); + + it("substitutes a placeholder for a missing description", () => { + // Datasets published without a description would otherwise render an empty paragraph and + // collapse the card's layout. + const el = render([entity({ description: undefined })]); + + expect(el.querySelector(".card-description")?.textContent?.trim()).toBe("No description available"); + }); + + it("uses the cached cover image when the entity has one", () => { + const el = render([entity({ id: 5, coverImageUrl: "has-cover" })]); + + const img = el.querySelector<HTMLImageElement>(".card-cover-image")!; + expect(img.getAttribute("src")).toBe(`${AppSettings.getApiEndpoint()}/dataset/5/cover`); + }); + + it("falls back to the default background when the cover image fails to load", () => { + // A cached cover URL can still 404; the inline error handler is the only thing that stops the + // card from showing a broken image. + const el = render([entity({ id: 5, coverImageUrl: "has-cover" })]); + const img = el.querySelector<HTMLImageElement>(".card-cover-image")!; + + img.dispatchEvent(new Event("error")); + + expect(img.src).toContain("card_background.jpg"); + }); + + it("labels the avatar with the entity id", () => { + const el = render([entity({ id: 42 })]); + + expect(el.querySelector("nz-avatar")?.textContent?.trim()).toBe("42"); + }); + + it("passes the owner through to the avatar, defaulting to an empty name", () => { + const withOwner = fixture.debugElement.queryAll(By.css("texera-user-avatar")); + expect(withOwner).toHaveLength(0); + + render([entity({ ownerName: "ada" }), entity({ id: 2, ownerName: undefined })]); + + const avatars = fixture.debugElement.queryAll(By.css("texera-user-avatar")); + expect(avatars.map(a => a.componentInstance.userName)).toEqual(["ada", ""]); + }); +});
