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 d7dc698c54 test(frontend): cover the dataset detail component's 
upload-progress paths (#7957)
d7dc698c54 is described below

commit d7dc698c54ca18e99cb89c98d2e0d3ccc537a875
Author: Xinyuan Lin <[email protected]>
AuthorDate: Tue Aug 25 21:22:08 2026 +0000

    test(frontend): cover the dataset detail component's upload-progress paths 
(#7957)
    
    ### What changes were proposed in this PR?
    
    `dataset-detail.component.spec.ts` goes from 195 tests to 203, closing
    the residue left after an earlier pass — mostly the upload-progress
    paths.
    
    Measured from raw lcov counters, same spec filter on both sides, `rm -rf
    coverage` between runs:
    
    | Counter | Before | After |
    |---|---|---|
    | Lines hit | 412/414 | **414/414 = 100%** |
    | Branch arms | 193/203 = 95.1% | **200/203 = 98.5%** (10 missed → 3) |
    | Functions | 120/120 | 120/120 |
    | Codecov's metric | 411/422 = 97.4% | **420/422 = 99.5%** |
    
    Functions were already 120/120 with zero `FNDA:0` records both before
    and after — worth stating, because twice in this campaign a file sat at
    high line coverage with functions uncovered and a binding whose handler
    the spec called directly stayed `FNDA:0` while looking covered. Not the
    case here.
    
    ### Verification
    
    16 mutations, **all 16 killed**, each named in the mutation table with
    its exact failure message.
    
    The first draft reported no survivors. **Seven mutants survived its
    202-test suite with exit 0** — including `.pop()` → `.shift()`,
    replacing a computed time-zone name with `""`, and several
    progress-index substitutions. All now die, and the search space
    additionally includes one mutant added on a fresh axis (the `", "`
    separator).
    
    Stated with the scope the reviewer correctly insisted on: **this reports
    the search space, not a proof about the file.** Sixteen mutants died;
    that is not the same claim as "the file is mutation-complete".
    
    Three repairs are worth naming because the original tests looked fine:
    
    - **The time-zone test was degenerate**, asserting on whatever the
    runner's ambient `Intl` formatter produced. It now stubs the formatter,
    so the assertion is about the component's own `.split(", ").pop()`
    parsing rather than the platform's output — and a second test covers the
    separator.
    - **Two single-index tests passed trivially.** "Ignores a hide request
    for a row that is gone" and the basename test each now assert a
    valid-index half alongside the invalid one, so an index substitution
    cannot slip through.
    - One reported failure mode was simply wrong and is rewritten: a mutant
    was described as surfacing a `TypeError` through a `.not.toThrow()`
    assertion, when the emission actually carries a valid percentage.
    
    ### Deliberately not included
    
    Two lines with three branch arms remain, and both are refused for the
    same reason: `percentage: progress.percentage ??
    this.uploadTasks[taskIndex].percentage ?? 0` (line 647) and its twin in
    the error handler (line 678) fall through a `??` whose right operand no
    caller can produce. Verified in the final lcov — line 647 is hit 28
    times with both arms at zero.
    
    After this bundle those are the **only** two lines in the file still
    carrying a missed arm, and the zero-hit set is empty.
    
    Also recorded honestly: **three** of the tests pin defensive paths
    production cannot reach (the first draft said two). They are kept
    because they document the guards, not because they earn coverage.
    
    No production file is touched, and the `node_modules` junction used for
    the run was removed before committing.
    
    ### Any related issues, documentation, discussions?
    
    Closes #7955
    
    ### How was this PR tested?
    
    ```
    npx ng test --watch=false --include="**/dataset-detail.component.spec.ts"
    ```
    
    ```
     Test Files  1 passed (1)
    ```
    
    `yarn format:ci` passes. `frontend/junit.xml` and `frontend/coverage/`
    are regenerated by every run and are not committed.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 5)
---
 .../dataset-detail.component.spec.ts               | 183 ++++++++++++++++++++-
 1 file changed, 182 insertions(+), 1 deletion(-)

diff --git 
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts
 
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts
index af5c6aed18..20570f4e95 100644
--- 
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts
+++ 
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts
@@ -22,7 +22,7 @@ import { ComponentFixture, TestBed } from 
"@angular/core/testing";
 import { By } from "@angular/platform-browser";
 import { NoopAnimationsModule } from "@angular/platform-browser/animations";
 import { ActivatedRoute, Router } from "@angular/router";
-import { of, Subject, throwError } from "rxjs";
+import { concat, of, Subject, throwError } from "rxjs";
 import { NzModalService } from "ng-zorro-antd/modal";
 import { NzResizableDirective } from "ng-zorro-antd/resizable";
 import { NzTooltipDirective } from "ng-zorro-antd/tooltip";
@@ -199,6 +199,72 @@ describe("DatasetDetailComponent upload queue", () => {
     });
   });
 
+  /**
+   * A progress event and the five-second hide timer both address a row by its 
index in
+   * `uploadTasks`, and that row can already be gone — dismissed by the user — 
by the time
+   * either arrives, so both lookups have to survive the miss. The completion 
path also has
+   * to pick a key for `uploadTimeMap` out of a name that may carry 
directories.
+   */
+  describe("progress bookkeeping", () => {
+    beforeEach(() => {
+      // The completion path arms a 5s row-hide timer; keep it off the real 
clock.
+      vi.useFakeTimers();
+    });
+
+    afterEach(() => {
+      vi.useRealTimers();
+    });
+
+    it("ignores progress for a row that is no longer listed", () => {
+      dropFiles("a.csv");
+      component.uploadTasks = []; // dismissed while a chunk was still in 
flight
+
+      expect(() => uploadSubjects[0].next({ filePath: "a.csv", percentage: 50, 
status: "uploading" })).not.toThrow();
+      // The late event must not resurrect the row or write a phantom index 
into the list.
+      expect(component.uploadTasks).toEqual([]);
+      expect(Object.keys(component.uploadTasks)).toHaveLength(0);
+    });
+
+    it("keys the upload time by the last path segment, falling back to the 
whole name", () => {
+      // Taking the segment after the last "/" yields "" for a name that ends 
in one, and
+      // keying the map under "" would collide every such upload onto one 
entry; the
+      // fallback keeps the name the caller gave instead.
+      dropFiles("nested/dir/");
+
+      finishUpload(0, "nested/dir/", 7);
+
+      expect(component.uploadTimeMap.get("nested/dir/")).toBe(7);
+      expect(component.uploadTimeMap.has("")).toBe(false);
+
+      // The reader of this map (user-dataset-staged-objects-list) looks a row 
up by
+      // `filePath.split("/").pop() || filePath`, so an ordinary nested name 
has to be
+      // keyed by its last segment here or the per-file time silently stops 
rendering.
+      dropFiles("dir/sub/a.csv");
+
+      finishUpload(1, "dir/sub/a.csv", 9);
+
+      expect(component.uploadTimeMap.get("a.csv")).toBe(9);
+      expect(component.uploadTimeMap.has("dir/sub/a.csv")).toBe(false);
+    });
+
+    it("ignores a hide request for a row that is gone", () => {
+      // Every one of scheduleHide's call sites already checks the index, so 
the -1 arm
+      // pins a defensive no-op rather than a reachable scenario: without the 
guard the
+      // lookup would read `filePath` off undefined and throw. The valid-index 
call that
+      // follows keeps a scheduleHide which does nothing at all from passing 
this test.
+      dropFiles("a.csv");
+      const before = [...component.uploadTasks];
+
+      expect(() => (component as any).scheduleHide(-1)).not.toThrow();
+      expect(component.uploadTasks).toEqual(before);
+
+      (component as any).scheduleHide(0);
+      vi.advanceTimersByTime(5000);
+
+      expect(component.uploadTasks).toEqual([]);
+    });
+  });
+
   /**
    * Aborting an in-flight upload has to survive the backend still finalizing 
the previous attempt:
    * the abort call is retried on 409 up to ABORT_RETRY_MAX_ATTEMPTS, a 404 
means it is already gone,
@@ -235,6 +301,12 @@ describe("DatasetDetailComponent upload queue", () => {
       expect(finalize).toHaveBeenCalledWith("[email protected]", 
"test-dataset", "a.txt", true);
       expect(component.uploadTasks.find(t => t.filePath === 
"a.txt")!.status).toBe("aborted");
       expect(onAborted).toHaveBeenCalledTimes(1);
+
+      // The aborted row goes on the same five-second hide timer a finished 
one does, so
+      // it clears itself out of the list instead of sitting there for the 
rest of the session.
+      vi.advanceTimersByTime(5000);
+
+      expect(component.uploadTasks.find(t => t.filePath === 
"a.txt")).toBeUndefined();
     });
 
     it("stops listening to the upload it aborted", () => {
@@ -410,6 +482,38 @@ describe("DatasetDetailComponent upload queue", () => {
       expect(component.activeCount).toBe(0);
       expect(onCanceled).toHaveBeenCalledTimes(1);
     });
+
+    it("tells the caller once even when the abort call reports more than 
once", () => {
+      // The callback is latched so that it fires exactly once no matter how 
many of the
+      // subscription's handlers reach it. HttpClient itself delivers a single 
response,
+      // so this drives the latch directly: a response followed by a stream 
failure runs
+      // the next handler and then the error handler, and both of them report 
done.
+      finalize.mockReturnValueOnce(
+        concat(
+          of({}),
+          throwError(() => ({ status: 500 }) as any)
+        )
+      );
+      const task = inFlight();
+      const onAborted = vi.fn();
+
+      component.onClickAbortUploadProgress(task as any, onAborted);
+
+      expect(onAborted).toHaveBeenCalledTimes(1);
+    });
+
+    it("aborts a task whose row was already dropped without resurrecting it", 
() => {
+      const task = inFlight();
+      component.uploadTasks = []; // the row was dismissed before the abort 
was clicked
+
+      component.onClickAbortUploadProgress(task as any);
+
+      expect(finalize).toHaveBeenCalledWith("[email protected]", 
"test-dataset", "a.txt", true);
+      // Writing "aborted" back at a missing index would leave a phantom "-1" 
property on
+      // the array, which neither a throw nor `.length` would reveal.
+      expect(component.uploadTasks).toEqual([]);
+      expect(Object.keys(component.uploadTasks)).toHaveLength(0);
+    });
   });
 
   /**
@@ -1221,6 +1325,68 @@ describe("DatasetDetailComponent behavior", () => {
       expect(component.coverImageUrl).toBeNull();
       expect(datasetServiceStub.getDatasetCoverUrl).not.toHaveBeenCalled();
     });
+
+    /**
+     * Stands in for the platform time-zone formatter so the assertions do not 
depend on
+     * whichever zone the machine running the suite sits in. `formatted` maps 
the requested
+     * `timeZoneName` option to the whole string the formatter would return, 
so the stub
+     * answers "long" and "short" differently the way a real formatter does — 
asking for the
+     * wrong one stays observable. Any call that does not ask for a zone name 
is delegated to
+     * the real constructor, since other code formats the same date through 
Intl.
+     */
+    const stubZoneFormatter = (formatted: Record<string, string>) => {
+      const realDateTimeFormat = Intl.DateTimeFormat;
+      return vi.spyOn(Intl, "DateTimeFormat").mockImplementation(function 
(locale?: any, options?: any) {
+        const requested = options?.timeZoneName as string | undefined;
+        return requested === undefined
+          ? new (realDateTimeFormat as any)(locale, options)
+          : ({ format: () => formatted[requested] ?? 
`<unstubbed:${requested}>` } as any);
+      } as any);
+    };
+
+    const renderTooltipWithCreationTime = () => {
+      datasetServiceStub.getDataset.mockReturnValue(
+        of(makeDashboardDataset({ dataset: makeDataset({ creationTime: 
CREATION_TS }) }))
+      );
+
+      createComponent();
+      component.did = 5;
+      component.retrieveDatasetInfo();
+    };
+
+    it("takes the tooltip's time zone from the spelled-out name the formatter 
appends", () => {
+      // The parenthetical is the segment after the last ", " of a long-form 
formatted
+      // date. Reading any other segment, or asking the formatter for the 
abbreviated
+      // zone, would put "11/14/2023" or "PST" in front of the user instead.
+      const zoned = stubZoneFormatter({
+        long: "11/14/2023, Pacific Standard Time",
+        short: "11/14/2023, PST",
+      });
+
+      try {
+        renderTooltipWithCreationTime();
+
+        expect(component.datasetCreationTimeTooltip).toMatch(/ \(Pacific 
Standard Time\)$/);
+      } finally {
+        // Vitest runs these specs without isolation, so a leaked global spy 
would
+        // follow the worker into the next spec file.
+        zoned.mockRestore();
+      }
+    });
+
+    it("leaves the tooltip's time zone empty when the runtime supplies no zone 
name", () => {
+      // A formatter that yields no zone name at all must render an empty 
parenthetical
+      // rather than leaking "undefined" into a user-visible tooltip.
+      const zoneless = stubZoneFormatter({ long: "", short: "" });
+
+      try {
+        renderTooltipWithCreationTime();
+
+        expect(component.datasetCreationTimeTooltip).toMatch(/ \(\)$/);
+      } finally {
+        zoneless.mockRestore();
+      }
+    });
   });
 
   describe("retrieveDatasetVersionList", () => {
@@ -1951,6 +2117,21 @@ describe("DatasetDetailComponent behavior", () => {
       expect(component.datasetDescription).toBe("old");
       expect(notificationServiceStub.error).toHaveBeenCalledWith("Failed to 
update dataset description");
     });
+
+    it("stores an empty description when the editor hands back nothing", () => 
{
+      // The editor round-trips whatever it was bound to, and a dataset whose 
stored
+      // description is null binds a nullish value straight back out. 
Persisting that
+      // verbatim would write `undefined` over a description instead of 
clearing it.
+      datasetServiceStub.updateDatasetDescription.mockReturnValue(of({}));
+      createComponent();
+      component.did = 5;
+      component.datasetDescription = "old";
+
+      component.onDatasetDescriptionChange(undefined as unknown as string);
+
+      
expect(datasetServiceStub.updateDatasetDescription).toHaveBeenCalledWith(5, "");
+      expect(component.datasetDescription).toBe("");
+    });
   });
 
   describe("copyCurrentFilePath", () => {

Reply via email to