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

commit 42d08a3701cd06542bcfa92728116302723b2536
Author: Meng Wang <[email protected]>
AuthorDate: Sun Aug 9 22:28:45 2026 -0700

    test(frontend): cover the remaining branches in three dashboard components 
(#7503)
    
    ### What changes were proposed in this PR?
    
    Covers the branches the three specs never reached. 22 new tests; every
    function in all
    three files is now executed.
    
    | file | statements | branches | functions |
    | --- | --- | --- | --- |
    | `user-computing-unit.component.ts` | 37/37 | 12/12 | 11/11 |
    | `user-dataset-file-renderer.component.ts` | 142/142 | 65/68 | 25/25 |
    | `files-uploader.component.ts` | 139/140 | 78/82 | 37/37 |
    
    **UserComputingUnitComponent** — the session subscription updating
    `isLogin`/`currentUid`,
    the mapping of fetched units into dashboard entries, the 1s poller (it
    refreshes on each
    tick and stops once the component is destroyed), and both arms of
    `terminateComputingUnit`.
    
    The poll test calls `ngOnInit()` directly rather than `detectChanges()`:
    the fixture's
    NgZone is created outside the `fakeAsync` zone, so a poll scheduled
    through it lands on
    the real timer queue where `tick()` cannot drive it.
    `discardPeriodicTasks()` drops the
    unbounded interval, and `fixture.destroy()` in `afterEach` keeps it from
    ticking into a
    later test.
    
    **UserDatasetFileRendererComponent** — the spreadsheet branch
    (previously unhit in full),
    the CSV empty-result and read-failure arms, and the guard that stops a
    fetch when the
    dataset ids are missing.
    
    The spreadsheet tests build a real `.xlsx` with JSZip (already a
    dependency, and already
    used this way in `user-workflow.component.spec.ts`) and run the real
    `read-excel-file`
    rather than mocking the module; one workbook leaves a gap in a row so
    both arms of the
    cell-to-string mapping run. jsdom's `Blob` has no `arrayBuffer()`, which
    is how
    `read-excel-file` reads its input, so the helper attaches the buffer it
    just built to that
    one blob instead of patching `Blob.prototype`. For CSV, `FileReader` is
    replaced with a
    fake that settles on a microtask, which is what makes the empty-result
    and error arms
    deterministic — `Papa.parse` cannot be spied here (the existing spec
    documents why).
    
    **FilesUploaderComponent** — the drop paths that never yield an
    uploadable file (oversized
    file, dropped directory, unreadable entry, including the singular/plural
    failure banner),
    the lookup paths (no dataset context, failed lookup, null result, and an
    unexpected failure
    of the whole drop with and without an error message), the fallback used
    when a conflicting
    path ends in a separator, the settings-request failure the constructor
    swallows, and the
    teardown that stops a late setting reaching a destroyed component.
    
    No production code was changed.
    
    ### Coverage that is not reachable
    
    Three branches and one statement stay uncovered because no test can
    reach them:
    
    - `user-dataset-file-renderer.component.ts:203` and `:223` — the
    `?? this.DEFAULT_MAX_SIZE` / `|| this.DEFAULT_MAX_SIZE` fallbacks. Both
    are reached only
    after `isPreviewSupported` has confirmed via `hasOwnProperty` that the
    key exists in
    `MIME_TYPE_SIZE_LIMITS_MB`, and every value in that map is a positive
    number, so neither
      fallback can be taken.
    - `user-dataset-file-renderer.component.ts:372` — `if (cell != "")`
    inside
    `for (const cell in row)`. `for...in` yields index strings ("0", "1",
    …), which are never
    `""`, so the condition is always true and the "filter out all empty row"
    step filters
      nothing. That is a defect rather than a coverage gap.
    - `files-uploader.component.ts:58` — a statement whose source range runs
    backwards
    (`58:35 -> 50:None`) into the `@Component` decorator: the
    compiler-emitted `ngDevMode`
    guard on the class declaration, not application code. The one genuinely
    reachable gap
    attributed near it — the constructor's `error: () => {}` arm — is now
    covered, taking
      function coverage to 37/37.
    
    - `user-dataset-file-renderer.component.ts` sets `isLoading = true`
    immediately above the
    `did && dvid && filePath` guard and never clears it when that guard
    fails, so a renderer
    given a `filePath` before its dataset ids shows a spinner that never
    stops. The id-guard
    test pins this with a comment saying it characterizes a defect and what
    to flip once it
      is fixed.
    
    ### Defects worth recording
    
    `getMimeType` uppercases a file's extension and looks it up as a **key**
    of `MIME_TYPES`.
    The Excel key is `MSEXCEL`, so only a file named `*.msexcel` resolves to
    `application/vnd.ms-excel`; a real `.xlsx` or `.xls` falls through to
    `OCTET_STREAM` and is
    rejected as "preview unsupported", which makes the whole spreadsheet
    branch dead for real
    spreadsheets. `.jpg` has the same problem (the key is `JPEG`). The new
    tests use the suffix
    the code actually accepts, with a comment saying so, rather than
    asserting an intent the
    code does not implement.
    
    ### Any related issues, documentation, discussions?
    
    Closes #7497.
    
    ### How was this PR tested?
    
    `ng test --watch=false` over the three specs — 86 passed (64 existing +
    22 new), run 3x for
    determinism. Coverage (`--coverage`) gives the table above. The failure
    path was verified by
    breaking one assertion in each of the three specs (red, non-zero exit)
    and restoring them;
    eslint and prettier are clean.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 4.8 [1M context])
---
 .../files-uploader.component.spec.ts               | 193 ++++++++++++++++++++-
 .../user-computing-unit.component.spec.ts          | 109 +++++++++++-
 .../user-dataset-file-renderer.component.spec.ts   | 180 +++++++++++++++++++
 3 files changed, 480 insertions(+), 2 deletions(-)

diff --git 
a/frontend/src/app/dashboard/component/user/files-uploader/files-uploader.component.spec.ts
 
b/frontend/src/app/dashboard/component/user/files-uploader/files-uploader.component.spec.ts
index 1882de68f8..e23668e997 100644
--- 
a/frontend/src/app/dashboard/component/user/files-uploader/files-uploader.component.spec.ts
+++ 
b/frontend/src/app/dashboard/component/user/files-uploader/files-uploader.component.spec.ts
@@ -17,7 +17,8 @@
  * under the License.
  */
 
-import { of } from "rxjs";
+import { of, Subject, throwError } from "rxjs";
+import { OnDestroy } from "@angular/core";
 import { NgxFileDropEntry } from "ngx-file-drop";
 import { NzModalService } from "ng-zorro-antd/modal";
 import { AdminSettingsService } from 
"../../../service/admin/settings/admin-settings.service";
@@ -106,6 +107,20 @@ describe("FilesUploaderComponent", () => {
     expect(build("0").singleFileUploadMaxSizeMiB).toBe(0);
   });
 
+  it("keeps the default upload size limit when the setting request fails", () 
=> {
+    // The component swallows the error on purpose so a settings outage cannot 
stop uploads.
+    const uploader = new FilesUploaderComponent(
+      { error: vi.fn() } as unknown as NotificationService,
+      {
+        getPublicSetting: vi.fn().mockReturnValue(throwError(() => new 
Error("settings unavailable"))),
+      } as unknown as AdminSettingsService,
+      datasetService as unknown as DatasetService,
+      { create: vi.fn() } as unknown as NzModalService
+    );
+
+    expect(uploader.singleFileUploadMaxSizeMiB).toBe(20);
+  });
+
   it("asks to resume failed multipart files and skip completed matching files 
in one retry batch", async () => {
     const emitted = new Promise<FileUploadItem[]>(resolve => 
component.uploadedFiles.subscribe(resolve));
 
@@ -310,4 +325,180 @@ describe("FilesUploaderComponent", () => {
     expect(component.fileUploadBannerType).toBe("warning");
     expect(component.fileUploadBannerMessage).toBe("heads up");
   });
+
+  /**
+   * Everything above drops well-formed files into a fully configured 
uploader. These cover what
+   * happens when the drop itself, or the lookups the drop depends on, do not 
go to plan — the
+   * paths that decide whether a file even reaches the upload queue.
+   */
+  describe("drops that do not yield an uploadable file", () => {
+    /** Resolves with whatever the component finally emits for a drop. */
+    const emissionOf = (): Promise<FileUploadItem[]> =>
+      new Promise<FileUploadItem[]>(resolve => 
component.uploadedFiles.subscribe(resolve));
+
+    it("rejects a single oversized file and reports it in the banner", async 
() => {
+      const notify = { error: vi.fn() };
+      component = new FilesUploaderComponent(
+        notify as unknown as NotificationService,
+        { getPublicSetting: vi.fn().mockReturnValue(of("0")) } as unknown as 
AdminSettingsService,
+        datasetService as unknown as DatasetService,
+        { create: vi.fn() } as unknown as NzModalService
+      );
+      const emitted = emissionOf();
+
+      component.fileDropped([droppedFile("big.csv", new File(["x"], 
"big.csv"))]);
+
+      expect(await emitted).toEqual([]);
+      expect(notify.error).toHaveBeenCalledWith("File big.csv's size exceeds 
the maximum limit of 0MiB.");
+      expect(component.fileUploadBannerType).toBe("error");
+      expect(component.fileUploadBannerMessage).toBe("1 file failed to be 
selected.");
+    });
+
+    it("pluralises the failure banner for more than one rejected file", async 
() => {
+      component = new FilesUploaderComponent(
+        { error: vi.fn() } as unknown as NotificationService,
+        { getPublicSetting: vi.fn().mockReturnValue(of("0")) } as unknown as 
AdminSettingsService,
+        datasetService as unknown as DatasetService,
+        { create: vi.fn() } as unknown as NzModalService
+      );
+      const emitted = emissionOf();
+
+      component.fileDropped([
+        droppedFile("a.csv", new File(["x"], "a.csv")),
+        droppedFile("b.csv", new File(["y"], "b.csv")),
+      ]);
+
+      expect(await emitted).toEqual([]);
+      expect(component.fileUploadBannerMessage).toBe("2 files failed to be 
selected.");
+    });
+
+    it("ignores a dropped directory", async () => {
+      const emitted = emissionOf();
+
+      component.fileDropped([{ relativePath: "folder", fileEntry: { isFile: 
false } } as unknown as NgxFileDropEntry]);
+
+      expect(await emitted).toEqual([]);
+    });
+
+    it("drops a file whose entry cannot be read", async () => {
+      const failingEntry = {
+        relativePath: "unreadable.csv",
+        fileEntry: {
+          isFile: true,
+          file: (_success: (file: File) => void, failure: (error: unknown) => 
void): void =>
+            failure(new Error("cannot read")),
+        },
+      } as unknown as NgxFileDropEntry;
+      const emitted = emissionOf();
+
+      component.fileDropped([failingEntry]);
+
+      expect(await emitted).toEqual([]);
+    });
+  });
+
+  describe("lookups the drop depends on", () => {
+    const emissionOf = (): Promise<FileUploadItem[]> =>
+      new Promise<FileUploadItem[]>(resolve => 
component.uploadedFiles.subscribe(resolve));
+
+    it("skips both lookups when the uploader has no dataset context", async () 
=> {
+      // The standalone (dataset-creation) usage: no owner/name and no did yet.
+      component.ownerEmail = "";
+      component.datasetName = "";
+      component.did = undefined;
+      const emitted = emissionOf();
+
+      component.fileDropped([droppedFile("fresh.csv", new File(["new"], 
"fresh.csv"))]);
+
+      expect((await emitted).map(item => item.name)).toEqual(["fresh.csv"]);
+      expect(datasetService.listMultipartUploads).not.toHaveBeenCalled();
+      expect(datasetService.findExistingUploadFiles).not.toHaveBeenCalled();
+    });
+
+    it("treats a failed lookup as nothing to reconcile", async () => {
+      datasetService.listMultipartUploads.mockReturnValue(throwError(() => new 
Error("offline")));
+      datasetService.findExistingUploadFiles.mockReturnValue(throwError(() => 
new Error("offline")));
+      const emitted = emissionOf();
+
+      component.fileDropped([droppedFile("failed.csv", new File(["half"], 
"failed.csv"))]);
+
+      // No dialog can be raised without paths, so the file goes straight 
through.
+      expect((await emitted).map(item => item.name)).toEqual(["failed.csv"]);
+      expect(modals).toEqual([]);
+    });
+
+    it("treats a null lookup result as nothing to reconcile", async () => {
+      datasetService.listMultipartUploads.mockReturnValue(of(null));
+      datasetService.findExistingUploadFiles.mockReturnValue(of(null));
+      const emitted = emissionOf();
+
+      component.fileDropped([droppedFile("failed.csv", new File(["half"], 
"failed.csv"))]);
+
+      expect((await emitted).map(item => item.name)).toEqual(["failed.csv"]);
+      expect(modals).toEqual([]);
+    });
+
+    it("reports an unexpected failure of the whole drop", async () => {
+      datasetService.listMultipartUploads.mockImplementation(() => {
+        throw new Error("lookup exploded");
+      });
+
+      component.fileDropped([droppedFile("any.csv", new File(["x"], 
"any.csv"))]);
+
+      await waitUntil(() => component.fileUploadingFinished);
+      expect(component.fileUploadBannerType).toBe("error");
+      expect(component.fileUploadBannerMessage).toBe("Unexpected error: lookup 
exploded");
+    });
+
+    it("reports an unexpected failure that carries no message", async () => {
+      datasetService.listMultipartUploads.mockImplementation(() => {
+        throw "lookup exploded";
+      });
+
+      component.fileDropped([droppedFile("any.csv", new File(["x"], 
"any.csv"))]);
+
+      await waitUntil(() => component.fileUploadingFinished);
+      expect(component.fileUploadBannerMessage).toBe("Unexpected error: lookup 
exploded");
+    });
+  });
+
+  it("stops tracking the size setting once destroyed", () => {
+    // @UntilDestroy() supplies the ngOnDestroy that ends the 
`untilDestroyed(this)`
+    // subscription; without it a late setting would still be applied to a 
dead component.
+    const setting = new Subject<string>();
+    const uploader = new FilesUploaderComponent(
+      { error: vi.fn() } as unknown as NotificationService,
+      { getPublicSetting: vi.fn().mockReturnValue(setting) } as unknown as 
AdminSettingsService,
+      datasetService as unknown as DatasetService,
+      { create: vi.fn() } as unknown as NzModalService
+    );
+
+    setting.next("50");
+    expect(uploader.singleFileUploadMaxSizeMiB).toBe(50);
+
+    (uploader as unknown as OnDestroy).ngOnDestroy();
+    setting.next("99");
+
+    expect(uploader.singleFileUploadMaxSizeMiB).toBe(50);
+  });
+
+  describe("dialog titles for paths without a file name", () => {
+    it("falls back to the whole path when the conflicting path ends in a 
separator", async () => {
+      datasetService.listMultipartUploads.mockReturnValue(of(["folder/"]));
+      datasetService.findExistingUploadFiles.mockReturnValue(of(["folder/"]));
+      const emitted = new Promise<FileUploadItem[]>(resolve => 
component.uploadedFiles.subscribe(resolve));
+
+      component.fileDropped([droppedFile("folder/", new File(["x"], "x"))]);
+
+      await waitUntil(() => modals.length === 1);
+      expect(modals[0].nzData.path).toBe("folder/");
+      modals[0].nzFooter.find(button => button.label === "Resume")?.onClick();
+
+      await waitUntil(() => modals.length === 2);
+      expect(modals[1].nzData.path).toBe("folder/");
+      modals[1].nzFooter.find(button => button.label === "Upload")?.onClick();
+
+      expect((await emitted).map(item => item.name)).toEqual(["folder/"]);
+    });
+  });
 });
diff --git 
a/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.spec.ts
 
b/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.spec.ts
index bd90137c96..11208fa940 100644
--- 
a/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.spec.ts
+++ 
b/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.spec.ts
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { ComponentFixture, TestBed, discardPeriodicTasks, fakeAsync, tick } 
from "@angular/core/testing";
 import { By } from "@angular/platform-browser";
 import { UserComputingUnitComponent } from "./user-computing-unit.component";
 import { ComputingUnitCreateModalComponent } from 
"../../../../common/component/computing-unit-create-modal/computing-unit-create-modal.component";
@@ -33,6 +33,8 @@ import { commonTestProviders } from 
"../../../../common/testing/test-utils";
 import { WorkflowComputingUnitManagingService } from 
"../../../../common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service";
 import { ComputingUnitStatusService } from 
"../../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service";
 import { MockComputingUnitStatusService } from 
"../../../../common/service/computing-unit/computing-unit-status/mock-computing-unit-status.service";
+import { ComputingUnitActionsService } from 
"../../../../common/service/computing-unit/computing-unit-actions/computing-unit-actions.service";
+import { NotificationService } from 
"../../../../common/service/notification/notification.service";
 import { of } from "rxjs";
 import type { Mocked } from "vitest";
 describe("UserComputingUnitComponent", () => {
@@ -94,4 +96,109 @@ describe("UserComputingUnitComponent", () => {
     modal.visibleChange.emit(false);
     expect(component.addComputeUnitModalVisible).toBe(false);
   });
+
+  describe("session, polling and termination", () => {
+    function makeUnit(cuid: number): DashboardWorkflowComputingUnit {
+      return {
+        computingUnit: {
+          cuid,
+          uid: 1,
+          name: `unit-${cuid}`,
+          creationTime: 0,
+          terminateTime: undefined,
+          type: "kubernetes",
+          uri: `uri-${cuid}`,
+          resource: {
+            cpuLimit: "1",
+            memoryLimit: "1Gi",
+            gpuLimit: "0",
+            jvmMemorySize: "1Gi",
+            shmSize: "64Mi",
+            nodeAddresses: [],
+          },
+        },
+        status: "Running",
+        metrics: { cpuUsage: "N/A", memoryUsage: "N/A" },
+        isOwner: true,
+        accessPrivilege: "WRITE",
+        ownerGoogleAvatar: "",
+        ownerName: "owner",
+      } as DashboardWorkflowComputingUnit;
+    }
+
+    afterEach(() => {
+      // ngOnInit starts a 1s interval; destroying the fixture unsubscribes it 
so it
+      // cannot tick into a later test.
+      fixture.destroy();
+    });
+
+    it("follows the signed-in user when the session changes", () => {
+      fixture.detectChanges();
+      const stubUserService = TestBed.inject(UserService) as unknown as 
StubUserService;
+
+      stubUserService.user = undefined;
+      stubUserService.userChangeSubject.next(undefined);
+
+      expect(component.isLogin).toBe(false);
+      expect(component.currentUid).toBeUndefined();
+    });
+
+    it("maps the fetched computing units into dashboard entries", () => {
+      const statusService = TestBed.inject(ComputingUnitStatusService);
+      vi.spyOn(statusService, 
"getAllComputingUnits").mockReturnValue(of([makeUnit(7)]));
+
+      fixture.detectChanges();
+
+      expect(component.allComputingUnits.map(u => 
u.computingUnit.cuid)).toEqual([7]);
+      expect(component.entries.map(e => e.id)).toEqual([7]);
+    });
+
+    it("refreshes the list on every poll tick, and stops once destroyed", 
fakeAsync(() => {
+      const statusService = TestBed.inject(ComputingUnitStatusService);
+      const refreshSpy = vi.spyOn(statusService, 
"refreshComputingUnitList").mockImplementation(() => {});
+
+      // ngOnInit directly rather than through detectChanges: the fixture's 
NgZone was
+      // created outside this fakeAsync zone, so a poll scheduled from there 
would land
+      // on the real timer queue and tick() could not drive it.
+      component.ngOnInit();
+      expect(refreshSpy).not.toHaveBeenCalled();
+
+      tick(1000);
+      expect(refreshSpy).toHaveBeenCalledTimes(1);
+      tick(1000);
+      expect(refreshSpy).toHaveBeenCalledTimes(2);
+
+      fixture.destroy();
+      tick(2000);
+      expect(refreshSpy).toHaveBeenCalledTimes(2);
+
+      // The interval is unbounded, so drop it before leaving the fakeAsync 
zone.
+      discardPeriodicTasks();
+    }));
+
+    it("hands a known computing unit to the actions service", () => {
+      const statusService = TestBed.inject(ComputingUnitStatusService);
+      vi.spyOn(statusService, 
"getAllComputingUnits").mockReturnValue(of([makeUnit(7)]));
+      const actions = TestBed.inject(ComputingUnitActionsService);
+      const terminateSpy = vi.spyOn(actions, 
"confirmAndTerminate").mockImplementation(() => {});
+      fixture.detectChanges();
+
+      component.terminateComputingUnit(7);
+
+      expect(terminateSpy).toHaveBeenCalledWith(7, 
component.allComputingUnits[0]);
+    });
+
+    it("reports an error instead of terminating an unknown computing unit", () 
=> {
+      const actions = TestBed.inject(ComputingUnitActionsService);
+      const terminateSpy = vi.spyOn(actions, 
"confirmAndTerminate").mockImplementation(() => {});
+      const notificationService = TestBed.inject(NotificationService);
+      const errorSpy = vi.spyOn(notificationService, 
"error").mockImplementation(() => {});
+      fixture.detectChanges();
+
+      component.terminateComputingUnit(404);
+
+      expect(errorSpy).toHaveBeenCalledWith("Invalid computing unit.");
+      expect(terminateSpy).not.toHaveBeenCalled();
+    });
+  });
 });
diff --git 
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.spec.ts
 
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.spec.ts
index 245f3be93c..0aadb792dd 100644
--- 
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.spec.ts
+++ 
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.spec.ts
@@ -26,6 +26,8 @@ import { DomSanitizer } from "@angular/platform-browser";
 import { commonTestProviders } from 
"../../../../../../common/testing/test-utils";
 import { of } from "rxjs";
 import * as Papa from "papaparse";
+import JSZip from "jszip";
+import readXlsxFile from "read-excel-file";
 import { SimpleChange, SimpleChanges } from "@angular/core";
 import { MarkdownModule } from "ngx-markdown";
 
@@ -414,6 +416,184 @@ describe("UserDatasetFileRendererComponent", () => {
       loadWith("notes.txt", blob);
       expect(component.isFileTypePreviewUnsupported).toBe(true);
     });
+
+    it("does not fetch when the dataset ids are missing", () => {
+      const datasetService = TestBed.inject(DatasetService);
+      const spy = vi.spyOn(datasetService, "retrieveDatasetVersionSingleFile");
+      // A supported, in-limit file, so the two pre-checks pass and the id 
guard is the
+      // only thing left to stop the request.
+      component.did = undefined;
+      component.dvid = 2;
+      component.filePath = "notes.txt";
+      component.fileSize = 100;
+
+      component.reloadFileContent();
+
+      expect(spy).not.toHaveBeenCalled();
+      // Characterizing a defect, not an intent: `isLoading` is set true just 
above the id
+      // guard and nothing on this path clears it, so the spinner keeps 
running until some
+      // later change triggers another reload. Flip this to `false` when that 
is fixed.
+      expect(component.isLoading).toBe(true);
+    });
+
+    /**
+     * The spreadsheet branch runs the real `read-excel-file`, so these build 
an actual
+     * .xlsx with JSZip (already a dependency, and already used this way in
+     * user-workflow.component.spec.ts) rather than mocking the parser module.
+     */
+    async function buildXlsxBlob(rowsXml: string): Promise<Blob> {
+      const zip = new JSZip();
+      zip.file(
+        "[Content_Types].xml",
+        `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types";>
+<Default Extension="rels" 
ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
+<Default Extension="xml" ContentType="application/xml"/>
+<Override PartName="/xl/workbook.xml" 
ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
+<Override PartName="/xl/worksheets/sheet1.xml" 
ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
+</Types>`
+      );
+      zip.file(
+        "_rels/.rels",
+        `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<Relationships 
xmlns="http://schemas.openxmlformats.org/package/2006/relationships";>
+<Relationship Id="rId1" 
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument";
 Target="xl/workbook.xml"/>
+</Relationships>`
+      );
+      zip.file(
+        "xl/workbook.xml",
+        `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"; 
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships";>
+<sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets>
+</workbook>`
+      );
+      zip.file(
+        "xl/_rels/workbook.xml.rels",
+        `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<Relationships 
xmlns="http://schemas.openxmlformats.org/package/2006/relationships";>
+<Relationship Id="rId1" 
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet";
 Target="worksheets/sheet1.xml"/>
+</Relationships>`
+      );
+      zip.file(
+        "xl/worksheets/sheet1.xml",
+        `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main";>
+<sheetData>${rowsXml}</sheetData>
+</worksheet>`
+      );
+      const content = await zip.generateAsync({ type: "arraybuffer" });
+      const blob = new Blob([content], { type: MIME_TYPES.MSEXCEL });
+      // jsdom's Blob has no `arrayBuffer()`, which is how read-excel-file 
reads its input.
+      // Hand back the buffer we just built rather than patching 
Blob.prototype globally.
+      (blob as unknown as { arrayBuffer: () => Promise<ArrayBuffer> 
}).arrayBuffer = () => Promise.resolve(content);
+      return blob;
+    }
+
+    // `getMimeType` looks an uppercased extension up as a key of MIME_TYPES, 
and the Excel key
+    // is MSEXCEL — so ".msexcel" is the only suffix that reaches this branch, 
and a real
+    // ".xlsx"/".xls" resolves to OCTET_STREAM and is rejected as unsupported. 
These tests use
+    // the suffix the code actually accepts; see the PR for the defect that 
implies.
+    const spreadsheetPath = "data.msexcel";
+
+    it("selects the spreadsheet viewer and parses the workbook into the 
table", async () => {
+      // Row 2 skips column B, so the parser yields a null cell and the 
empty-string arm of
+      // the cell mapping is exercised alongside the populated one.
+      const blob = await buildXlsxBlob(`
+        <row r="1"><c r="A1"><v>1</v></c><c r="B1"><v>2</v></c></row>
+        <row r="2"><c r="A2"><v>3</v></c><c r="C2"><v>4</v></c></row>
+      `);
+
+      loadWith(spreadsheetPath, blob);
+
+      await vi.waitFor(() => expect(component.displayXlsx).toBe(true));
+      // Every row is padded to the widest one, so the header picks up a 
trailing empty cell
+      // from row 2's column C.
+      expect(component.tableDataHeader).toEqual(["1", "2", ""]);
+      expect(component.tableContent[0]).toEqual(["3", "", "4"]);
+    });
+
+    it("leaves the spreadsheet viewer off for a workbook with no rows", async 
() => {
+      const blob = await buildXlsxBlob("");
+
+      loadWith(spreadsheetPath, blob);
+      // Parsing the same workbook here is the barrier: it starts strictly 
after the
+      // component's read of an identical blob and runs the identical promise 
chain, so it
+      // cannot settle first. No timers and no counting of microtask turns.
+      await readXlsxFile(blob as unknown as File);
+
+      expect(component.displayXlsx).toBe(false);
+      expect(component.tableDataHeader).toEqual([]);
+    });
+  });
+
+  /**
+   * papaparse reads a File through `FileReader`, so replacing the global with 
a fake that
+   * settles on a microtask makes both the empty-result and the read-failure 
arms of the CSV
+   * branch deterministic — jsdom's real timing is never relied on.
+   */
+  describe("CSV parsing outcomes", () => {
+    let realFileReader: typeof globalThis.FileReader;
+    let outcome: { text: string } | { fail: true };
+
+    class FakeFileReader {
+      public result: string | null = null;
+      public error: unknown = null;
+      public onload: ((event: unknown) => void) | null = null;
+      public onerror: ((event: unknown) => void) | null = null;
+      readAsText(): void {
+        queueMicrotask(() => {
+          if ("fail" in outcome) {
+            this.error = new Error("read failed");
+            this.onerror?.({});
+          } else {
+            this.result = outcome.text;
+            this.onload?.({ target: { result: outcome.text } });
+          }
+        });
+      }
+      abort(): void {}
+    }
+
+    beforeEach(() => {
+      realFileReader = globalThis.FileReader;
+      (globalThis as unknown as { FileReader: unknown }).FileReader = 
FakeFileReader;
+    });
+
+    afterEach(() => {
+      (globalThis as unknown as { FileReader: unknown }).FileReader = 
realFileReader;
+    });
+
+    function loadCsv(): void {
+      const datasetService = TestBed.inject(DatasetService);
+      vi.spyOn(datasetService, 
"retrieveDatasetVersionSingleFile").mockReturnValue(
+        of(new Blob(["ignored"], { type: MIME_TYPES.CSV }))
+      );
+      component.did = 1;
+      component.dvid = 2;
+      component.filePath = "data.csv";
+      component.fileSize = undefined;
+      component.reloadFileContent();
+    }
+
+    it("leaves the table empty when the CSV has no rows", async () => {
+      outcome = { text: "" };
+
+      loadCsv();
+      await vi.waitFor(() => expect(component.displayCSV).toBe(true));
+
+      expect(component.tableDataHeader).toEqual([]);
+      expect(component.tableContent).toEqual([]);
+    });
+
+    it("flags a loading error when the CSV cannot be read", async () => {
+      const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => 
{});
+      outcome = { fail: true };
+
+      loadCsv();
+
+      await vi.waitFor(() => expect(component.isFileLoadingError).toBe(true));
+      expect(consoleSpy).toHaveBeenCalled();
+    });
   });
 });
 

Reply via email to