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-6831-3149eb5ce55de0db029bdba2c3106684e6c65a74 in repository https://gitbox.apache.org/repos/asf/texera.git
commit 362e6baca217b29d6df411d669e029ab5bc1af7d Author: Meng Wang <[email protected]> AuthorDate: Thu Jul 23 16:20:14 2026 -0700 test(frontend): extend HuggingFaceComponent coverage for template bindings and fallback branches (#6831) ### What changes were proposed in this PR? Extends the `HuggingFaceComponent` spec to close the coverage gaps that remain after #6773. **Note on the issue's premise:** #6828 cites ~80% / 57 uncovered lines, but that is pre-#6773 codecov data — the component's **statement/line coverage is already 100%**. Measuring locally (`ng test --include <spec> --coverage`) showed the real remaining gaps are the **template's event bindings** (the existing spec never rendered/clicked anything) and a handful of **fallback branches**. This PR targets exactly those: | | before | after | |---|---|---| | `hugging-face.component.html` statements | 84.67% | **97.58%** | | `hugging-face.component.html` branches | 77.77% | **94.44%** | | `hugging-face.component.ts` branches | 91.04% | **94.78%** | 14 added tests: - **Rendered-template interactions** (new `By.css` + `triggerEventHandler` block — the existing spec called methods directly, so none of the template's `(click)`/`(ngModelChange)` bindings were exercised): selecting a model item, clearing the selected model, the Prev/Next pagination buttons, both Retry buttons (models-load and tasks-load failures), the search input, the search clear icon, the task dropdown, and the search-specific empty-state message. - **Fallback branches**: the empty-tag `|| "text-generation"` fallback in `loadAllModels` / `retryLoad` / the server-side search, and persisting a task when the field has no `parent` and the form has no `task` control. **Determinism:** the added tests reuse the spec's existing hygiene (`invalidateHuggingFaceModelCache()` per test, `fixture.destroy()` + `http.verify()` in `afterEach`, `fakeAsync`/`tick` for the 300 ms debounce). I deliberately left three candidate branches uncovered rather than ship a fragile test: the two "in-flight fetch canceled" poll branches (couldn't be driven deterministically) and the formly validation-message block (needs extra formly config). Two others (`snapshot` hasOwnProperty, `defaults[key] ?? ""`) are unreachable — every key is always populated. No production code was changed. ### Any related issues, documentation, discussions? Closes #6828 ### How was this PR tested? Extended unit tests, run locally in `frontend/`: ``` ng test --watch=false --include src/app/workspace/component/hugging-face/hugging-face.component.spec.ts # Test Files 1 passed (1) | Tests 85 passed (85) — 3 consecutive runs, 0 flakes prettier --write <spec> # unchanged eslint <spec> # clean ``` The failure path was verified by deliberately breaking a new assertion and confirming the suite exits non-zero. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 4.8 [1M context]) --------- Signed-off-by: Meng Wang <[email protected]> Co-authored-by: Copilot Autofix powered by AI <[email protected]> --- .../hugging-face/hugging-face.component.spec.ts | 189 +++++++++++++++++++++ 1 file changed, 189 insertions(+) diff --git a/frontend/src/app/workspace/component/hugging-face/hugging-face.component.spec.ts b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.spec.ts index ad31f0ca6c..201361767d 100644 --- a/frontend/src/app/workspace/component/hugging-face/hugging-face.component.spec.ts +++ b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.spec.ts @@ -19,6 +19,7 @@ import { ComponentFixture, TestBed, discardPeriodicTasks, fakeAsync, tick } from "@angular/core/testing"; import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; +import { By } from "@angular/platform-browser"; import { FormControl, FormGroup } from "@angular/forms"; import { FieldTypeConfig } from "@ngx-formly/core"; import { AppSettings } from "../../../common/app-setting"; @@ -1384,4 +1385,192 @@ describe("HuggingFaceComponent (TestBed)", () => { expect(component.loading).toBe(false); }); }); + + // ── Rendered-template interactions (the (click)/(ngModelChange) bindings) ── + + describe("template interactions", () => { + it("clicking a model item selects it", () => { + initComponent("text-generation", buildModels(3)); + fixture.detectChanges(); + + const item = fixture.debugElement.query(By.css(".hf-model-item")); + expect(item).toBeTruthy(); + item.triggerEventHandler("click", null); + + expect(component.formControl.value).toBe("model/model-0"); + }); + + it("clicking the close icon on the selected model clears the value", () => { + initComponent("text-generation", buildModels(3)); + component.formControl.setValue("model/model-0"); + fixture.detectChanges(); + + const closeIcon = fixture.debugElement.query(By.css(".hf-selected-model i")); + expect(closeIcon).toBeTruthy(); + closeIcon.triggerEventHandler("click", null); + + expect(component.formControl.value).toBe(""); + }); + + it("the pagination buttons move to the next and previous page", () => { + initComponent("text-generation", buildModels(120)); // 3 pages of 50 + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css(".hf-pagination button")); + expect(buttons.length).toBe(2); + + buttons[1].triggerEventHandler("click", null); + expect(component.currentPage).toBe(1); + + fixture.detectChanges(); + buttons[0].triggerEventHandler("click", null); + expect(component.currentPage).toBe(0); + }); + + it("clicking Retry re-fetches the models after a load failure", () => { + const { field } = buildFieldWithFormGroup("text-generation"); + component.field = field; + fixture.detectChanges(); + flushIconRequests(); + http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse()); + http + .expectOne(req => req.url.startsWith(`${API}/huggingface/models`)) + .flush("boom", { status: 500, statusText: "Server Error" }); + fixture.detectChanges(); + + const retry = fixture.debugElement.query(By.css(".hf-error button")); + expect(retry).toBeTruthy(); + retry.triggerEventHandler("click", null); + + http.expectOne(req => req.url.startsWith(`${API}/huggingface/models`)).flush(buildModels(2)); + expect(component.errorMessage).toBeNull(); + }); + + it("clicking Retry re-fetches the tasks after a tasks-load failure", () => { + const { field } = buildFieldWithFormGroup("text-generation"); + component.field = field; + fixture.detectChanges(); + flushIconRequests(); + http.expectOne(`${API}/huggingface/tasks`).flush("boom", { status: 500, statusText: "Server Error" }); + http.expectOne(req => req.url.startsWith(`${API}/huggingface/models`)).flush(buildModels(2)); + fixture.detectChanges(); + + const retry = fixture.debugElement.query(By.css(".hf-error button")); + expect(retry).toBeTruthy(); + retry.triggerEventHandler("click", null); + + http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse()); + expect(component.tasksError).toBeNull(); + }); + + it("the search box forwards typed text to onSearchInput", () => { + initComponent("text-generation", buildModels(3)); + fixture.detectChanges(); + + const input = fixture.debugElement.query(By.css("input[nz-input]")); + expect(input).toBeTruthy(); + input.triggerEventHandler("ngModelChange", "model-1"); + + expect(component.searchText).toBe("model-1"); + }); + + it("clicking the clear icon resets the search", () => { + initComponent("text-generation", buildModels(3)); + component.onSearchInput("model-1"); + fixture.detectChanges(); + + const clear = fixture.debugElement.query(By.css("i[nztype='close-circle']")); + expect(clear).toBeTruthy(); + clear.triggerEventHandler("click", null); + + expect(component.searchText).toBe(""); + }); + + it("changing the task dropdown loads the models for the new task", () => { + initComponent("text-generation", buildModels(3)); + fixture.detectChanges(); + + const select = fixture.debugElement.query(By.css("nz-select")); + expect(select).toBeTruthy(); + select.triggerEventHandler("ngModelChange", "image-classification"); + + http.expectOne(`${API}/huggingface/models?task=image-classification`).flush(buildModels(2)); + expect(component.selectedTaskTag).toBe("image-classification"); + }); + + it("shows the search-specific empty message when a search matches nothing", () => { + initComponent("text-generation", buildModels(3)); + component.onSearchInput("no-such-model"); + fixture.detectChanges(); + + const empty = fixture.debugElement.query(By.css(".hf-empty")); + expect(empty).toBeTruthy(); + expect(empty.nativeElement.textContent).toContain('No models found for "no-such-model".'); + }); + }); + + // ── Fallback branches ── + + describe("fallback branches", () => { + it("falls back to the text-generation tag when the selected tag is empty", () => { + initComponent("text-generation", buildModels(2)); + invalidateHuggingFaceModelCache(); + component.selectedTaskTag = ""; + + component.retryLoad(); + + http.expectOne(`${API}/huggingface/models?task=text-generation`).flush(buildModels(1)); + expect(component.pagedModels.length).toBe(1); + }); + + it("server-side search falls back to the text-generation tag when the tag is empty", fakeAsync(() => { + initComponent("text-generation", buildModels(2)); + component.truncated = true; + component.selectedTaskTag = ""; + + component.onSearchInput("query"); + tick(300); + + http.expectOne(`${API}/huggingface/models?task=text-generation&search=query`).flush(buildModels(1)); + expect(component.pagedModels.length).toBe(1); + })); + + it("uses the current selection when no task is present on the model or form", fakeAsync(() => { + // taskValue "" -> getCurrentTaskTag() returns undefined, so the ?? fallback is taken. + const { field } = buildFieldWithFormGroup(""); + component.field = field; + fixture.detectChanges(); + flushIconRequests(); + http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse()); + http.expectOne(req => req.url.startsWith(`${API}/huggingface/models`)).flush(buildModels(2)); + tick(0); // the ngOnInit setTimeout re-sync, which also takes the ?? fallback + + component.onTaskSelected("summarization"); + + http.expectOne(`${API}/huggingface/models?task=summarization`).flush(buildModels(1)); + expect(component.selectedTaskTag).toBe("summarization"); + })); + + it("persists the task when the field has no parent and the form has no task control", () => { + const formGroup = new FormGroup({ modelId: new FormControl("") }); + const field = { + key: "modelId", + formControl: formGroup.get("modelId")! as FormControl, + form: formGroup, + model: { modelId: "" } as Record<string, unknown>, + props: {}, + options: { detectChanges: vi.fn() }, + } as unknown as FieldTypeConfig; // no `parent` -> the `field.parent ?? field` fallback + component.field = field; + fixture.detectChanges(); + flushIconRequests(); + http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse()); + http.expectOne(req => req.url.startsWith(`${API}/huggingface/models`)).flush(buildModels(2)); + + expect(() => component.onTaskSelected("translation")).not.toThrow(); + + http.expectOne(`${API}/huggingface/models?task=translation`).flush(buildModels(1)); + expect(component.selectedTaskTag).toBe("translation"); + }); + }); });
