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-6773-a8bb1ec7c0747aaa08ac58ccd46f96724e3a7547
in repository https://gitbox.apache.org/repos/asf/texera.git

commit 6495ebd84ab0dc7f4f6b1d4fbc09ff051f4cfd10
Author: Xinyuan Lin <[email protected]>
AuthorDate: Wed Jul 22 20:57:19 2026 -0700

    test(frontend): extend HuggingFaceComponent unit test coverage (#6773)
    
    ### What changes were proposed in this PR?
    
    Extends `hugging-face.component.spec.ts` with 10 tests (62 -> 72) for
    `HuggingFaceComponent`, covering the second-instance task/model poll
    callbacks (success, error, and cancellation-during-flight), the
    poll-interval re-entry guards, the
    `getCurrentTaskTag`/`persistTaskSelection`/`snapshotTaskState`
    fallbacks, the model form-fallback reads/writes, and null response-body
    handling. Coverage 87% -> ~100% (38 -> 0 uncovered statements). No
    existing tests modified.
    
    ### Any related issues, documentation, discussions?
    
    Closes #6769.
    
    ### How was this PR tested?
    
    `ng test --include='**/hugging-face.component.spec.ts'` -> 72/72
    passing. `yarn format:ci` passes.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 4.8 [1M context])
---
 .../hugging-face/hugging-face.component.spec.ts    | 349 +++++++++++++++++++++
 1 file changed, 349 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 51f7e8999c..ad31f0ca6c 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
@@ -1035,4 +1035,353 @@ describe("HuggingFaceComponent (TestBed)", () => {
       expect(component.loading).toBe(false);
     });
   });
+
+  // ── Poll-path resolution (second instance waits on an in-flight fetch) ──
+  //
+  // The poll uses setInterval created inside ngOnInit (via detectChanges), 
which is a
+  // real timer not captured by fakeAsync, so these use real timers. Rather 
than sleep a
+  // fixed interval (flaky under CI load), waitForPoll polls until the awaited 
condition
+  // holds, up to a generous cap.
+
+  describe("poll-path resolution", () => {
+    /** Poll (real timers) until `done()` holds, up to a generous timeout. */
+    const waitForPoll = (done: () => boolean, timeoutMs = 3000): Promise<void> 
=>
+      new Promise<void>((resolve, reject) => {
+        const started = Date.now();
+        const timer = setInterval(() => {
+          if (done()) {
+            clearInterval(timer);
+            resolve();
+          } else if (Date.now() - started > timeoutMs) {
+            clearInterval(timer);
+            reject(new Error("waitForPoll: condition not met within timeout"));
+          }
+        }, 20);
+      });
+
+    it("resolves both the task and model poll callbacks when the in-flight 
fetch succeeds", async () => {
+      // comp1 leaves BOTH tasks and models in flight.
+      const { field: field1 } = buildFieldWithFormGroup("text-generation");
+      component.field = field1;
+      fixture.detectChanges();
+      flushIconRequests();
+
+      // comp2 finds tasks + models in flight → enters BOTH poll paths.
+      const fixture2 = TestBed.createComponent(HuggingFaceComponent);
+      const component2 = fixture2.componentInstance;
+      const { field: field2 } = buildFieldWithFormGroup("text-generation");
+      component2.field = field2;
+      fixture2.detectChanges();
+      http.match(req => req.url.startsWith("assets/")).forEach(req => 
req.flush("<svg></svg>"));
+
+      expect(component2.tasksLoading).toBe(true);
+      expect(component2.loading).toBe(true);
+
+      // comp1's fetches complete → module-level caches are populated.
+      http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse());
+      http.expectOne(req => 
req.url.startsWith(`${API}/huggingface/models`)).flush(buildModels(7));
+
+      // Let the 200ms poll intervals observe the now-populated caches.
+      await waitForPoll(
+        () => (component2 as any).taskPollInterval === null && (component2 as 
any).modelPollInterval === null
+      );
+      flushIconRequests();
+
+      expect(component2.tasksLoading).toBe(false);
+      expect(component2.taskOptions).toEqual(buildTaskResponse());
+      expect((component2 as any).taskPollInterval).toBeNull();
+
+      expect(component2.loading).toBe(false);
+      expect(component2.pagedModels.length).toBe(7);
+      expect((component2 as any).modelPollInterval).toBeNull();
+
+      fixture2.destroy();
+    });
+
+    it("resolves both poll callbacks to the error state when the in-flight 
fetch fails", async () => {
+      const { field: field1 } = buildFieldWithFormGroup("text-generation");
+      component.field = field1;
+      fixture.detectChanges();
+      flushIconRequests();
+
+      const fixture2 = TestBed.createComponent(HuggingFaceComponent);
+      const component2 = fixture2.componentInstance;
+      const { field: field2 } = buildFieldWithFormGroup("text-generation");
+      component2.field = field2;
+      fixture2.detectChanges();
+      http.match(req => req.url.startsWith("assets/")).forEach(req => 
req.flush("<svg></svg>"));
+
+      expect(component2.tasksLoading).toBe(true);
+      expect(component2.loading).toBe(true);
+
+      // comp1's fetches fail → error caches are populated.
+      http.expectOne(`${API}/huggingface/tasks`).error(new 
ProgressEvent("error"));
+      http.expectOne(req => 
req.url.startsWith(`${API}/huggingface/models`)).error(new 
ProgressEvent("error"));
+
+      await waitForPoll(
+        () => (component2 as any).taskPollInterval === null && (component2 as 
any).modelPollInterval === null
+      );
+      flushIconRequests();
+
+      // Task poll falls back to the static list and surfaces the cached error.
+      expect(component2.tasksLoading).toBe(false);
+      expect(component2.taskOptions).toEqual(STATIC_TASK_OPTIONS);
+      expect(component2.tasksError).toBeTruthy();
+      expect((component2 as any).taskPollInterval).toBeNull();
+
+      // Model poll surfaces the cached error message.
+      expect(component2.loading).toBe(false);
+      expect(component2.errorMessage).toBeTruthy();
+      expect(component2.pagedModels.length).toBe(0);
+      expect((component2 as any).modelPollInterval).toBeNull();
+
+      fixture2.destroy();
+    });
+
+    it("stops polling and falls back when the in-flight fetch is invalidated 
before it resolves", async () => {
+      // comp1 leaves tasks + models in flight.
+      const { field: field1 } = buildFieldWithFormGroup("text-generation");
+      component.field = field1;
+      fixture.detectChanges();
+      flushIconRequests();
+
+      // comp2 enters both poll paths.
+      const fixture2 = TestBed.createComponent(HuggingFaceComponent);
+      const component2 = fixture2.componentInstance;
+      const { field: field2 } = buildFieldWithFormGroup("text-generation");
+      component2.field = field2;
+      fixture2.detectChanges();
+      http.match(req => req.url.startsWith("assets/")).forEach(req => 
req.flush("<svg></svg>"));
+
+      expect(component2.tasksLoading).toBe(true);
+      expect(component2.loading).toBe(true);
+
+      // Invalidate while comp1's fetches are still in flight: this 
unsubscribes the
+      // in-flight requests WITHOUT populating any cache, so the poll 
callbacks must
+      // detect the canceled fetch and fall back rather than wait forever.
+      invalidateHuggingFaceModelCache();
+
+      await waitForPoll(
+        () => (component2 as any).taskPollInterval === null && (component2 as 
any).modelPollInterval === null
+      );
+      flushIconRequests();
+
+      // Task poll detects the canceled fetch → stops and falls back to the 
static list.
+      expect(component2.tasksLoading).toBe(false);
+      expect(component2.taskOptions).toEqual(STATIC_TASK_OPTIONS);
+      expect((component2 as any).taskPollInterval).toBeNull();
+
+      // Model poll detects the canceled fetch → stops without an error.
+      expect(component2.loading).toBe(false);
+      expect((component2 as any).modelPollInterval).toBeNull();
+
+      fixture2.destroy();
+      // Drain the now-canceled comp1 requests so the shared afterEach 
verify() passes.
+      http.match(req => req.url.includes("/huggingface/"));
+    });
+
+    it("replaces (does not leak) an existing poll interval when re-entering a 
poll path", () => {
+      // comp1 leaves tasks + models in flight.
+      const { field: field1 } = buildFieldWithFormGroup("text-generation");
+      component.field = field1;
+      fixture.detectChanges();
+      flushIconRequests();
+
+      // comp2 enters both poll paths, establishing both poll intervals.
+      const fixture2 = TestBed.createComponent(HuggingFaceComponent);
+      const component2 = fixture2.componentInstance;
+      const { field: field2 } = buildFieldWithFormGroup("text-generation");
+      component2.field = field2;
+      fixture2.detectChanges();
+      http.match(req => req.url.startsWith("assets/")).forEach(req => 
req.flush("<svg></svg>"));
+
+      const firstTaskInterval = (component2 as any).taskPollInterval;
+      const firstModelInterval = (component2 as any).modelPollInterval;
+      expect(firstTaskInterval).not.toBeNull();
+      expect(firstModelInterval).not.toBeNull();
+
+      // Re-enter the task poll path (retry) while comp1's tasks fetch is 
still in flight:
+      // the stale interval is cleared and replaced rather than leaked.
+      component2.retryTasksLoad();
+      const secondTaskInterval = (component2 as any).taskPollInterval;
+      expect(secondTaskInterval).not.toBeNull();
+      expect(secondTaskInterval).not.toBe(firstTaskInterval);
+
+      // Re-enter the model poll path (retry) while comp1's models fetch is 
still in flight.
+      component2.retryLoad();
+      const secondModelInterval = (component2 as any).modelPollInterval;
+      expect(secondModelInterval).not.toBeNull();
+      expect(secondModelInterval).not.toBe(firstModelInterval);
+
+      // Cleanup: destroy clears the current intervals; flush comp1's 
in-flight requests.
+      fixture2.destroy();
+      http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse());
+      http.match(req => 
req.url.startsWith(`${API}/huggingface/models`)).forEach(req => req.flush([]));
+    });
+  });
+
+  // ── getCurrentTaskTag: field.form fallback ──
+
+  describe("getCurrentTaskTag field.form fallback", () => {
+    it("reads the task from field.form when model.task and the parent control 
are empty", () => {
+      const formGroup = new FormGroup({
+        task: new FormControl("summarization"),
+        modelId: new FormControl(""),
+      });
+      // formControl has NO parent, so the parent-control branch yields 
undefined
+      // and resolution falls through to field.form.get("task").
+      const standaloneModelId = new FormControl("");
+      const field = {
+        key: "modelId",
+        formControl: standaloneModelId,
+        form: formGroup,
+        model: { task: "" },
+        props: {},
+        parent: { fieldGroup: [] },
+        options: { detectChanges: vi.fn() },
+      } as unknown as FieldTypeConfig;
+
+      component.field = field;
+      fixture.detectChanges();
+
+      http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse());
+      
http.expectOne(`${API}/huggingface/models?task=summarization`).flush(buildModels(2));
+
+      expect(component.selectedTaskTag).toBe("summarization");
+    });
+  });
+
+  // ── persistTaskSelection: distinct parent task control ──
+
+  describe("persistTaskSelection with a distinct parent control", () => {
+    it("writes the task to BOTH the field-form control and a different parent 
control", () => {
+      const fieldFormGroup = new FormGroup({ task: new FormControl("") });
+      const parentGroup = new FormGroup({
+        task: new FormControl(""),
+        modelId: new FormControl(""),
+      });
+      const field = {
+        key: "modelId",
+        formControl: parentGroup.get("modelId")!,
+        form: fieldFormGroup,
+        model: { task: "" },
+        props: {},
+        parent: { fieldGroup: [] },
+        options: { detectChanges: vi.fn() },
+      } as unknown as FieldTypeConfig;
+
+      component.field = field;
+      fixture.detectChanges();
+
+      http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse());
+      
http.expectOne(`${API}/huggingface/models?task=text-generation`).flush(buildModels(2));
+
+      // The field-form task control and the (distinct) parent task control 
both updated.
+      expect(fieldFormGroup.get("task")!.value).toBe("text-generation");
+      expect(parentGroup.get("task")!.value).toBe("text-generation");
+    });
+  });
+
+  // ── snapshotTaskState: empty-tag guard ──
+
+  describe("snapshotTaskState guard", () => {
+    it("does not record a snapshot for an empty tag", () => {
+      initComponent();
+
+      expect(() => (component as any).snapshotTaskState("")).not.toThrow();
+      expect((component as any).taskStateByTag.has("")).toBe(false);
+      expect((component as any).taskStateByTag.size).toBe(0);
+    });
+  });
+
+  // ── read/write field value: model fallback when the form lacks controls ──
+
+  describe("field value model fallback", () => {
+    it("snapshots from and restores to the model when the form lacks those 
controls", () => {
+      // Form only carries task + modelId; the remaining task-scoped keys live 
on the model.
+      const formGroup = new FormGroup({
+        task: new FormControl("text-generation"),
+        modelId: new FormControl(""),
+      });
+      const model: Record<string, unknown> = {
+        task: "text-generation",
+        modelId: "",
+        systemPrompt: "custom-from-model",
+        maxNewTokens: 111,
+        promptColumn: "pc",
+      };
+      const field = {
+        key: "modelId",
+        formControl: formGroup.get("modelId")!,
+        form: formGroup,
+        model,
+        props: {},
+        parent: { fieldGroup: [] },
+        options: { detectChanges: vi.fn() },
+      } as unknown as FieldTypeConfig;
+
+      component.field = field;
+      fixture.detectChanges();
+
+      http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse());
+      
http.expectOne(`${API}/huggingface/models?task=text-generation`).flush(buildModels(2));
+
+      // First visit to a new task: snapshot reads text-generation values off 
the model,
+      // then reset writes first-visit defaults back onto the model.
+      component.onTaskSelected("image-classification");
+      
http.expectOne(`${API}/huggingface/models?task=image-classification`).flush([]);
+      expect(model["systemPrompt"]).toBe("You are a helpful assistant.");
+      expect(model["maxNewTokens"]).toBe(256);
+
+      // Switching back restores the snapshotted model values (written via 
writeFieldValue → model).
+      component.onTaskSelected("text-generation");
+      expect(model["systemPrompt"]).toBe("custom-from-model");
+      expect(model["maxNewTokens"]).toBe(111);
+      expect(model["promptColumn"]).toBe("pc");
+    });
+
+    it("does not throw when the field has no backing model", () => {
+      const formGroup = new FormGroup({
+        task: new FormControl(""),
+        modelId: new FormControl(""),
+      });
+      const field = {
+        key: "modelId",
+        formControl: formGroup.get("modelId")!,
+        form: formGroup,
+        model: null,
+        props: {},
+        parent: { fieldGroup: [] },
+        options: { detectChanges: vi.fn() },
+      } as unknown as FieldTypeConfig;
+
+      component.field = field;
+      fixture.detectChanges();
+
+      http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse());
+      
http.expectOne(`${API}/huggingface/models?task=text-generation`).flush(buildModels(2));
+
+      // onTaskSelected exercises snapshot/reset/restore with a null model — 
must not throw.
+      expect(() => 
component.onTaskSelected("image-classification")).not.toThrow();
+      
http.expectOne(`${API}/huggingface/models?task=image-classification`).flush([]);
+      expect(component.selectedTaskTag).toBe("image-classification");
+    });
+  });
+
+  // ── null response body ──
+
+  describe("null response body", () => {
+    it("treats a null model response body as an empty list", () => {
+      const { field } = buildFieldWithFormGroup("text-generation");
+      component.field = field;
+      fixture.detectChanges();
+
+      http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse());
+      http.expectOne(req => 
req.url.startsWith(`${API}/huggingface/models`)).flush(null);
+
+      expect(component.pagedModels.length).toBe(0);
+      expect(component.totalPages).toBe(1);
+      expect(component.loading).toBe(false);
+    });
+  });
 });

Reply via email to